Files
blog-eleventy-indiekit/interactions.njk
rmdes fc9f5968da 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 <noreply@anthropic.com>
2026-02-18 21:28:01 +01:00

507 lines
26 KiB
Plaintext

---
layout: layouts/base.njk
title: Interactions
permalink: /interactions/
---
<div class="page-header mb-6 sm:mb-8">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Interactions</h1>
<p class="text-surface-600 dark:text-surface-400">Activity across the IndieWeb - both my engagement and responses to my content.</p>
</div>
{# Tab navigation for Outbound/Inbound #}
<div x-data="interactionsApp()" x-init="init()">
{# Tab buttons #}
<div class="flex border-b border-surface-200 dark:border-surface-700 mb-6">
<button
@click="activeTab = 'outbound'"
:class="activeTab === 'outbound' ? 'border-primary-500 text-primary-600 dark:text-primary-400' : 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors">
My Activity
<span class="ml-1 text-xs text-surface-400">(outbound)</span>
</button>
<button
@click="activeTab = 'inbound'"
:class="activeTab === 'inbound' ? 'border-primary-500 text-primary-600 dark:text-primary-400' : 'border-transparent text-surface-500 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors">
Received
<span class="ml-1 text-xs text-surface-400">(inbound)</span>
<span x-show="totalInbound > 0" x-text="totalInbound" class="ml-1 px-1.5 py-0.5 text-xs bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full"></span>
</button>
</div>
{# ===== OUTBOUND TAB - My Activity ===== #}
<div x-show="activeTab === 'outbound'" x-transition>
<p class="text-surface-600 dark:text-surface-400 text-sm mb-6">Content I've interacted with across the web.</p>
<div class="grid gap-4 sm:gap-6 md:grid-cols-2 lg:grid-cols-3">
{# Likes #}
<a href="/likes/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-red-100 dark:bg-red-900/30 rounded-full">
<svg class="w-6 h-6 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-primary-600 dark:group-hover:text-primary-400">Likes</h2>
<p class="text-sm text-surface-500">{{ collections.likes.length }} item{% if collections.likes.length != 1 %}s{% endif %}</p>
</div>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">Content I've appreciated across the web.</p>
</a>
{# Replies #}
<a href="/replies/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-full">
<svg class="w-6 h-6 text-primary-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-primary-600 dark:group-hover:text-primary-400">Replies</h2>
<p class="text-sm text-surface-500">{{ collections.replies.length }} item{% if collections.replies.length != 1 %}s{% endif %}</p>
</div>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">My responses to posts across the web.</p>
</a>
{# Bookmarks #}
<a href="/bookmarks/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-yellow-100 dark:bg-yellow-900/30 rounded-full">
<svg class="w-6 h-6 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-primary-600 dark:group-hover:text-primary-400">Bookmarks</h2>
<p class="text-sm text-surface-500">{{ collections.bookmarks.length }} item{% if collections.bookmarks.length != 1 %}s{% endif %}</p>
</div>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">Links I've saved for later.</p>
</a>
{# Reposts #}
<a href="/reposts/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-full">
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-primary-600 dark:group-hover:text-primary-400">Reposts</h2>
<p class="text-sm text-surface-500">{{ collections.reposts.length }} item{% if collections.reposts.length != 1 %}s{% endif %}</p>
</div>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">Content I've shared from others.</p>
</a>
{# Photos #}
<a href="/photos/" class="block p-6 bg-surface-100 dark:bg-surface-800 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors group">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 group-hover:text-primary-600 dark:group-hover:text-primary-400">Photos</h2>
<p class="text-sm text-surface-500">{{ collections.photos.length }} item{% if collections.photos.length != 1 %}s{% endif %}</p>
</div>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">Photo posts and images.</p>
</a>
</div>
<div class="mt-12 p-6 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">About IndieWeb Interactions</h2>
<p class="text-surface-600 dark:text-surface-400 text-sm mb-4">
These pages show different types of IndieWeb interactions I've made. Each type uses specific microformat properties
to indicate the relationship to the original content.
</p>
<ul class="text-sm text-surface-600 dark:text-surface-400 space-y-1">
<li><strong>Likes</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-like-of</code></li>
<li><strong>Replies</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-in-reply-to</code></li>
<li><strong>Bookmarks</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-bookmark-of</code></li>
<li><strong>Reposts</strong> use <code class="bg-surface-200 dark:bg-surface-700 px-1 rounded">u-repost-of</code></li>
</ul>
</div>
</div>
{# ===== INBOUND TAB - Received Webmentions ===== #}
<div x-show="activeTab === 'inbound'" x-transition>
<div class="flex items-center justify-between mb-6">
<p class="text-surface-600 dark:text-surface-400 text-sm">Webmentions and interactions others have made with my content.</p>
<button
@click="fetchWebmentions()"
:disabled="loading"
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 rounded-lg transition-colors disabled:opacity-50">
<svg class="w-4 h-4" :class="loading ? 'animate-spin' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
</div>
{# Loading state #}
<div x-show="loading && !webmentions.length" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
<p class="mt-4 text-surface-500">Loading webmentions...</p>
</div>
{# Setup required state — shown when webmentions proxy is not configured #}
<div x-show="notConfigured" class="p-6 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mb-6">
<h3 class="text-lg font-semibold text-amber-800 dark:text-amber-200 mb-3">Webmentions Not Configured</h3>
<p class="text-amber-700 dark:text-amber-300 text-sm mb-4">
To receive inbound webmentions, you need to set up the webmentions proxy plugin
(<code class="bg-amber-100 dark:bg-amber-900/40 px-1 rounded">@rmdes/indiekit-endpoint-webmentions-proxy</code>).
</p>
<ol class="text-sm text-amber-700 dark:text-amber-300 space-y-2 list-decimal list-inside">
<li>Register your domain at <a href="https://webmention.io" target="_blank" rel="noopener" class="underline font-medium">webmention.io</a> and get your API token</li>
<li>Set the <code class="bg-amber-100 dark:bg-amber-900/40 px-1 rounded">WEBMENTION_IO_TOKEN</code> environment variable</li>
<li>Ensure the webmentions proxy plugin is installed and configured in your Indiekit config</li>
<li>Restart Indiekit to apply the changes</li>
</ol>
</div>
{# Error state — only shown for real errors, not missing config #}
<div x-show="error && !notConfigured" class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 mb-6">
<p x-text="error"></p>
</div>
{# Filter by type #}
<div x-show="!notConfigured && (!loading || webmentions.length)" class="flex flex-wrap gap-2 mb-6">
<button
@click="filterType = 'all'"
:class="filterType === 'all' ? 'bg-primary-500 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300'"
class="px-3 py-1.5 text-sm rounded-full transition-colors">
All <span x-text="'(' + totalInbound + ')'" class="text-xs opacity-75"></span>
</button>
<button
@click="filterType = 'like-of'"
:class="filterType === 'like-of' ? 'bg-red-500 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300'"
class="px-3 py-1.5 text-sm rounded-full transition-colors">
❤️ Likes <span x-text="'(' + likes.length + ')'" class="text-xs opacity-75"></span>
</button>
<button
@click="filterType = 'repost-of'"
:class="filterType === 'repost-of' ? 'bg-green-500 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300'"
class="px-3 py-1.5 text-sm rounded-full transition-colors">
🔄 Reposts <span x-text="'(' + reposts.length + ')'" class="text-xs opacity-75"></span>
</button>
<button
@click="filterType = 'in-reply-to'"
:class="filterType === 'in-reply-to' ? 'bg-primary-500 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300'"
class="px-3 py-1.5 text-sm rounded-full transition-colors">
💬 Replies <span x-text="'(' + replies.length + ')'" class="text-xs opacity-75"></span>
</button>
<button
@click="filterType = 'mention-of'"
:class="filterType === 'mention-of' ? 'bg-yellow-500 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300'"
class="px-3 py-1.5 text-sm rounded-full transition-colors">
📣 Mentions <span x-text="'(' + mentions.length + ')'" class="text-xs opacity-75"></span>
</button>
</div>
{# Webmentions list #}
<div x-show="!notConfigured && (!loading || webmentions.length)" class="space-y-4">
<template x-for="wm in paginatedWebmentions" :key="wm['wm-id']">
<div class="p-4 bg-surface-100 dark:bg-surface-800 rounded-lg">
<div class="flex gap-3">
{# Author avatar #}
<a :href="wm.author?.url || '#'" target="_blank" rel="noopener" class="flex-shrink-0">
<img
:src="wm.author?.photo || '/images/default-avatar.svg'"
:alt="wm.author?.name || 'Anonymous'"
class="w-10 h-10 rounded-full"
loading="lazy"
onerror="this.src='/images/default-avatar.svg'"
>
</a>
<div class="flex-1 min-w-0">
{# Header with author, type badge, and date #}
<div class="flex flex-wrap items-center gap-2 mb-1">
<a :href="wm.author?.url || '#'" target="_blank" rel="noopener" class="font-semibold text-surface-900 dark:text-surface-100 hover:underline" x-text="wm.author?.name || 'Anonymous'"></a>
{# Type badge #}
<span x-show="wm['wm-property'] === 'like-of'" class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-full">
❤️ liked
</span>
<span x-show="wm['wm-property'] === 'repost-of'" class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full">
🔄 reposted
</span>
<span x-show="wm['wm-property'] === 'in-reply-to'" class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full">
💬 replied
</span>
<span x-show="wm['wm-property'] === 'mention-of'" class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 rounded-full">
📣 mentioned
</span>
<span x-show="wm['wm-property'] === 'bookmark-of'" class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-full">
🔖 bookmarked
</span>
{# Platform badge (from conversations API) #}
<span x-show="wm.platform === 'mastodon'" class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded-full" title="Mastodon">
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
</span>
<span x-show="wm.platform === 'bluesky'" class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400 rounded-full" title="Bluesky">
<svg class="w-3 h-3" viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg>
</span>
<a :href="wm.url || '#'" target="_blank" rel="noopener" class="text-xs text-surface-500 hover:underline">
<time :datetime="wm.published || wm['wm-received']" x-text="formatDate(wm.published || wm['wm-received'])"></time>
</a>
</div>
{# Content (for replies) #}
<div x-show="wm.content?.text" class="text-surface-700 dark:text-surface-300 text-sm mt-2" x-text="truncateText(wm.content?.text, 280)"></div>
{# Target URL - which of my posts this is about #}
<div class="mt-2 text-xs text-surface-500">
<span>on </span>
<a :href="wm['wm-target']" class="text-primary-600 dark:text-primary-400 hover:underline" x-text="formatTargetUrl(wm['wm-target'])"></a>
</div>
</div>
</div>
</div>
</template>
{# Empty state #}
<div x-show="!loading && filteredWebmentions.length === 0" class="text-center py-12 text-surface-500">
<p>No webmentions found for this filter.</p>
</div>
{# Pagination / Load more #}
<div x-show="hasMore" class="text-center pt-4">
<button
@click="loadMore()"
:disabled="loadingMore"
class="px-4 py-2 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 rounded-lg text-sm transition-colors disabled:opacity-50">
<span x-text="loadingMore ? 'Loading...' : 'Load More'"></span>
</button>
</div>
</div>
{# Info box #}
<div class="mt-12 p-6 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">About Webmentions</h2>
<p class="text-surface-600 dark:text-surface-400 text-sm mb-4">
Webmentions are a W3C standard for cross-site communication. When someone likes, reposts, or replies to my content
from their own site (or via Bluesky/Mastodon bridges), I receive a webmention notification.
</p>
<ul class="text-sm text-surface-600 dark:text-surface-400 space-y-1">
<li><strong>Likes</strong> - Someone appreciated this post</li>
<li><strong>Reposts</strong> - Someone shared this post</li>
<li><strong>Replies</strong> - Someone responded to this post</li>
<li><strong>Mentions</strong> - Someone linked to this post</li>
</ul>
</div>
</div>
</div>
<script>
function interactionsApp() {
return {
activeTab: 'inbound',
loading: false,
loadingMore: false,
error: null,
notConfigured: false,
webmentions: [],
filterType: 'all',
page: 0,
perPage: 50,
hasMore: true,
refreshInterval: null,
get likes() {
return this.webmentions.filter(wm => wm['wm-property'] === 'like-of');
},
get reposts() {
return this.webmentions.filter(wm => wm['wm-property'] === 'repost-of');
},
get replies() {
return this.webmentions.filter(wm => wm['wm-property'] === 'in-reply-to');
},
get mentions() {
return this.webmentions.filter(wm => wm['wm-property'] === 'mention-of');
},
get totalInbound() {
return this.webmentions.length;
},
get filteredWebmentions() {
if (this.filterType === 'all') return this.webmentions;
return this.webmentions.filter(wm => wm['wm-property'] === this.filterType);
},
get paginatedWebmentions() {
// Client-side pagination of filtered results
return this.filteredWebmentions;
},
async init() {
await this.fetchWebmentions();
// Auto-refresh every 5 minutes (skip if not configured)
if (!this.notConfigured) {
this.refreshInterval = setInterval(() => this.fetchWebmentions(true), 5 * 60 * 1000);
}
},
async fetchWebmentions(silent = false) {
if (!silent) {
this.loading = true;
this.page = 0;
this.webmentions = [];
this.hasMore = true;
}
this.error = null;
try {
// 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 [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;
// Merge and deduplicate - conversations items (with platform field) take priority
const merged = this.mergeAndDeduplicate(
wmData.children || [],
convData.children || []
);
// 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;
});
this.webmentions = merged;
this.hasMore = (wmData.children || []).length === this.perPage;
} catch (err) {
this.error = `Failed to load webmentions: ${err.message}`;
console.error('[Interactions]', err);
} finally {
this.loading = false;
}
},
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 wmUrl = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`;
const convUrl = `/conversations/api/mentions?per-page=${this.perPage}&page=${this.page}`;
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: [] })),
]);
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 {
this.loadingMore = false;
}
},
formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
},
truncateText(text, maxLen = 280) {
if (!text) return '';
if (text.length <= maxLen) return text;
return text.slice(0, maxLen).trim() + '...';
},
formatTargetUrl(url) {
if (!url) return '';
try {
const u = new URL(url);
// Return just the pathname, trimmed
let path = u.pathname;
if (path.length > 50) {
path = path.slice(0, 47) + '...';
}
return path || '/';
} catch {
return url;
}
}
};
}
</script>