refactor: unified owner reply threading via conversations API

- Remove self-mention filter (siteOrigin, isSelfMention) from webmentions.js
- Remove build-time self-mention filter from eleventy.config.js
- processWebmentions() now separates is_owner items and threads them
  under parent interaction cards via threadOwnerReplies()
- owner:detected handler reduced to wireReplyButtons() only
- Remove loadOwnerReplies() and Alpine.store replies from comments.js
- Owner replies now come from conversations API with parent_url metadata

Confab-Link: http://localhost:8080/sessions/184584f4-67e1-485a-aba8-02ac34a600fe
This commit is contained in:
Ricardo
2026-03-15 12:45:55 +01:00
parent 55927722cc
commit c7c0f4e0a4
4 changed files with 118 additions and 144 deletions
+14
View File
@@ -509,6 +509,20 @@ export default async function () {
3. **CSS utilities:** Add custom utilities to `css/tailwind.css` 3. **CSS utilities:** Add custom utilities to `css/tailwind.css`
4. **Rebuild CSS:** `npm run build:css` (or `make build:css` in parent repo) 4. **Rebuild CSS:** `npm run build:css` (or `make build:css` in parent repo)
## Performance Debugging
For diagnosing and fixing Eleventy build performance issues, see the comprehensive guide at `/home/rick/code/indiekit-dev/docs/eleventy-debugging-guide.md`.
**Quick diagnostic steps:**
1. **Baseline:** `time npx @11ty/eleventy --quiet` (run 3x, take median)
2. **Benchmark:** `DEBUG=Eleventy:Benchmark* npx @11ty/eleventy` — find entries >15% of total or with call count matching page count
3. **Classify:** Network requests (high avg, low count) vs. redundant computation (low avg, high count) vs. client-side bloat (fast build, low Lighthouse)
4. **Fix:** Timeout + cache for network; memoize with `Map` for per-page computation; Web Components for client-side bloat
5. **Verify:** Re-measure against baseline
**Relevant to this theme:** Data files in `_data/` that fetch from external APIs (GitHub, Mastodon, Bluesky, YouTube, Funkwhale, Last.fm) are Pattern A candidates — always use `eleventy-fetch` with appropriate `duration` and handle failures gracefully. The OG image generation hook is a Pattern B candidate — it already uses batch spawning and manifest caching to manage memory and avoid redundant work.
## Anti-Patterns ## Anti-Patterns
1.**Forgetting to update submodule** after changes 1.**Forgetting to update submodule** after changes
+2 -8
View File
@@ -820,14 +820,8 @@ export default function (eleventyConfig) {
urlsToCheck.add(`${siteUrl}${contentUrl}`.replace(/\/$/, "")); urlsToCheck.add(`${siteUrl}${contentUrl}`.replace(/\/$/, ""));
} }
// Filter merged data matching any of our URLs, excluding self-mentions // Filter merged data matching any of our URLs
// (owner replies sent via webmention-sender appear as webmentions on own posts) const matched = merged.filter((wm) => urlsToCheck.has(wm["wm-target"]));
const matched = merged.filter((wm) => {
if (!urlsToCheck.has(wm["wm-target"])) return false;
const source = wm["wm-source"] || wm.url || "";
if (source.startsWith(siteUrl)) return false;
return true;
});
// Deduplicate cross-source: same author + same interaction type = same mention // Deduplicate cross-source: same author + same interaction type = same mention
// (webmention.io and conversations API may both report the same like/reply) // (webmention.io and conversations API may both report the same like/reply)
+3 -20
View File
@@ -1,6 +1,6 @@
/** /**
* Client-side comments component (Alpine.js) * Client-side comments component (Alpine.js)
* Handles IndieAuth flow, comment submission, display, and owner replies * Handles IndieAuth flow, comment submission, display, and owner detection
* *
* Registered via Alpine.data() so the component is available * Registered via Alpine.data() so the component is available
* regardless of script loading order. * regardless of script loading order.
@@ -12,7 +12,6 @@ document.addEventListener("alpine:init", () => {
isOwner: false, isOwner: false,
profile: null, profile: null,
syndicationTargets: {}, syndicationTargets: {},
replies: [],
}); });
Alpine.data("commentsSection", (targetUrl) => ({ Alpine.data("commentsSection", (targetUrl) => ({
@@ -40,9 +39,7 @@ document.addEventListener("alpine:init", () => {
await this.checkOwner(); await this.checkOwner();
await this.loadComments(); await this.loadComments();
if (this.isOwner) { if (this.isOwner) {
await this.loadOwnerReplies(); // Notify webmentions.js that owner is detected (for reply buttons)
// Notify webmentions.js that owner state + replies are ready
// (alpine:initialized fires before these async fetches resolve)
document.dispatchEvent(new CustomEvent("owner:detected")); document.dispatchEvent(new CustomEvent("owner:detected"));
} }
this.handleAuthReturn(); this.handleAuthReturn();
@@ -84,7 +81,7 @@ document.addEventListener("alpine:init", () => {
Alpine.store("owner").syndicationTargets = this.syndicationTargets; Alpine.store("owner").syndicationTargets = this.syndicationTargets;
// Note: owner:detected event is dispatched from init() after // Note: owner:detected event is dispatched from init() after
// loadOwnerReplies() completes, so replies are available in the store // this completes, so the Alpine store is populated before the event fires
} }
} }
} catch { } catch {
@@ -92,20 +89,6 @@ document.addEventListener("alpine:init", () => {
} }
}, },
async loadOwnerReplies() {
try {
const url = `/comments/api/owner-replies?target=${encodeURIComponent(this.targetUrl)}`;
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
// Store for webmentions component to use via Alpine store
Alpine.store("owner").replies = data.children || [];
}
} catch {
// Silently fail
}
},
startReply(commentId, platform, replyUrl, syndicateTo) { startReply(commentId, platform, replyUrl, syndicateTo) {
this.replyingTo = { commentId, platform, replyUrl, syndicateTo }; this.replyingTo = { commentId, platform, replyUrl, syndicateTo };
this.replyText = ""; this.replyText = "";
+99 -116
View File
@@ -13,11 +13,6 @@
if (!target || !domain) return; if (!target || !domain) return;
// Extract site origin for filtering self-mentions
// (owner replies sent via webmention-sender appear as webmentions on own posts)
var siteOrigin = '';
try { siteOrigin = new URL(target).origin; } catch(e) {}
// Use server-side proxy to keep webmention.io token secure // Use server-side proxy to keep webmention.io token secure
// Fetch both with and without trailing slash since webmention.io // Fetch both with and without trailing slash since webmention.io
// stores targets inconsistently (Bridgy sends different formats) // stores targets inconsistently (Bridgy sends different formats)
@@ -60,13 +55,9 @@
function processWebmentions(allChildren) { function processWebmentions(allChildren) {
if (!allChildren || !allChildren.length) return; if (!allChildren || !allChildren.length) return;
// Filter out self-mentions (may exist in older cached data) // Separate owner replies (threaded under parent) from regular interactions
allChildren = allChildren.filter(function(wm) { var ownerReplies = allChildren.filter(function(wm) { return wm.is_owner && wm.parent_url; });
if (!siteOrigin) return true; var regularItems = allChildren.filter(function(wm) { return !wm.is_owner; });
var source = wm['wm-source'] || wm.url || '';
return !source.startsWith(siteOrigin);
});
if (!allChildren.length) return;
let mentionsToShow; let mentionsToShow;
if (hasBuildTimeSection) { if (hasBuildTimeSection) {
@@ -94,7 +85,7 @@
if (li.dataset.wmUrl) renderedReplies.add(li.dataset.wmUrl); if (li.dataset.wmUrl) renderedReplies.add(li.dataset.wmUrl);
}); });
mentionsToShow = allChildren.filter(function(wm) { mentionsToShow = regularItems.filter(function(wm) {
var prop = wm['wm-property'] || 'mention-of'; var prop = wm['wm-property'] || 'mention-of';
if (prop === 'in-reply-to') { if (prop === 'in-reply-to') {
// Skip replies whose source URL is already rendered // Skip replies whose source URL is already rendered
@@ -106,44 +97,108 @@
return true; return true;
}); });
} else { } else {
// No build-time section - show ALL webmentions from API // No build-time section - show ALL regular webmentions from API
mentionsToShow = allChildren; mentionsToShow = regularItems;
} }
if (!mentionsToShow.length) return; if (mentionsToShow.length) {
// 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');
// Group by type if (likes.length) {
const likes = mentionsToShow.filter((m) => m['wm-property'] === 'like-of'); appendAvatars('.webmention-likes .facepile, .webmention-likes .avatar-row', likes, 'likes');
const reposts = mentionsToShow.filter((m) => m['wm-property'] === 'repost-of'); updateCount('.webmention-likes h3', likes.length, 'Like');
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 (reposts.length) {
if (likes.length) { appendAvatars('.webmention-reposts .facepile, .webmention-reposts .avatar-row', reposts, 'reposts');
appendAvatars('.webmention-likes .facepile, .webmention-likes .avatar-row', likes, 'likes'); updateCount('.webmention-reposts h3', reposts.length, 'Repost');
updateCount('.webmention-likes h3', likes.length, 'Like'); }
if (replies.length) {
appendReplies('.webmention-replies ul', replies);
updateCount('.webmention-replies h3', replies.length, 'Repl', 'ies', 'y');
}
if (mentions.length) {
appendMentions('.webmention-mentions ul', mentions);
updateCount('.webmention-mentions h3', mentions.length, 'Mention');
}
// Update total count in main header
updateTotalCount(mentionsToShow.length);
} }
// Append new reposts // Thread owner replies under their parent interaction cards
if (reposts.length) { threadOwnerReplies(ownerReplies);
appendAvatars('.webmention-reposts .facepile, .webmention-reposts .avatar-row', reposts, 'reposts'); }
updateCount('.webmention-reposts h3', reposts.length, 'Repost');
}
// Append new replies function threadOwnerReplies(ownerReplies) {
if (replies.length) { if (!ownerReplies || !ownerReplies.length) return;
appendReplies('.webmention-replies ul', replies);
updateCount('.webmention-replies h3', replies.length, 'Repl', 'ies', 'y');
}
// Append new mentions ownerReplies.forEach(function(reply) {
if (mentions.length) { var parentUrl = reply.parent_url;
appendMentions('.webmention-mentions ul', mentions); if (!parentUrl) return;
updateCount('.webmention-mentions h3', mentions.length, 'Mention');
}
// Update total count in main header // Find the interaction card whose URL matches the parent
updateTotalCount(mentionsToShow.length); var matchingLi = document.querySelector('.webmention-replies li[data-wm-url="' + CSS.escape(parentUrl) + '"]');
if (!matchingLi) return;
var slot = matchingLi.querySelector('.wm-owner-reply-slot');
if (!slot) return;
// Skip if already rendered (dedup by reply URL)
if (slot.querySelector('[data-reply-url="' + CSS.escape(reply.url) + '"]')) return;
var replyCard = document.createElement('div');
replyCard.className = 'p-3 bg-surface-100 dark:bg-surface-900 rounded-lg border-l-2 border-amber-400 dark:border-amber-600';
replyCard.dataset.replyUrl = reply.url || '';
var innerDiv = document.createElement('div');
innerDiv.className = 'flex items-start gap-2';
var avatar = document.createElement('div');
avatar.className = 'w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900 flex-shrink-0 flex items-center justify-center text-xs font-bold';
avatar.textContent = (reply.author && reply.author.name ? reply.author.name[0] : 'O').toUpperCase();
var contentArea = document.createElement('div');
contentArea.className = 'flex-1';
var headerRow = document.createElement('div');
headerRow.className = 'flex items-center gap-2 flex-wrap';
var nameSpan = document.createElement('span');
nameSpan.className = 'font-medium text-sm';
nameSpan.textContent = (reply.author && reply.author.name) || 'Owner';
var authorBadge = document.createElement('span');
authorBadge.className = 'inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full';
authorBadge.textContent = 'Author';
var timeEl = document.createElement('time');
timeEl.className = 'text-xs text-surface-600 dark:text-surface-400 font-mono';
timeEl.dateTime = reply.published || '';
timeEl.textContent = formatDate(reply.published);
headerRow.appendChild(nameSpan);
headerRow.appendChild(authorBadge);
headerRow.appendChild(timeEl);
var textDiv = document.createElement('div');
textDiv.className = 'mt-1 text-sm prose dark:prose-invert';
textDiv.textContent = (reply.content && reply.content.text) || '';
contentArea.appendChild(headerRow);
contentArea.appendChild(textDiv);
innerDiv.appendChild(avatar);
innerDiv.appendChild(contentArea);
replyCard.appendChild(innerDiv);
slot.appendChild(replyCard);
});
} }
// Try cached data first (renders instantly on refresh) // Try cached data first (renders instantly on refresh)
@@ -168,13 +223,6 @@
const wmItems = [...(wmData1.children || []), ...(wmData2.children || [])]; const wmItems = [...(wmData1.children || []), ...(wmData2.children || [])];
const convItems = [...(convData1.children || []), ...(convData2.children || [])]; const convItems = [...(convData1.children || []), ...(convData2.children || [])];
// Filter out self-mentions (owner's own replies appearing as webmentions)
function isSelfMention(wm) {
if (!siteOrigin) return false;
var source = wm['wm-source'] || wm.url || '';
return source.startsWith(siteOrigin);
}
// Build dedup sets from conversations items (richer metadata, take priority) // Build dedup sets from conversations items (richer metadata, take priority)
const convUrls = new Set(convItems.map(c => c.url).filter(Boolean)); const convUrls = new Set(convItems.map(c => c.url).filter(Boolean));
const seen = new Set(); const seen = new Set();
@@ -182,7 +230,6 @@
// Add conversations items first (they have platform provenance) // Add conversations items first (they have platform provenance)
for (const wm of convItems) { for (const wm of convItems) {
if (isSelfMention(wm)) continue;
const key = wm['wm-id'] || wm.url; const key = wm['wm-id'] || wm.url;
if (key && !seen.has(key)) { if (key && !seen.has(key)) {
seen.add(key); seen.add(key);
@@ -198,9 +245,8 @@
if (authorUrl) authorActions.add(authorUrl + '::' + action); if (authorUrl) authorActions.add(authorUrl + '::' + action);
} }
// Add webmention-io items, skipping duplicates and self-mentions // Add webmention-io items, skipping duplicates
for (const wm of wmItems) { for (const wm of wmItems) {
if (isSelfMention(wm)) continue;
const key = wm['wm-id']; const key = wm['wm-id'];
if (seen.has(key)) continue; if (seen.has(key)) continue;
// Also skip if same source URL exists in conversations // Also skip if same source URL exists in conversations
@@ -765,72 +811,9 @@
}); });
} }
// Show reply buttons and wire click handlers if owner is detected // Show reply buttons when owner is detected
// Listen for custom event dispatched by comments.js after async owner check // Listen for custom event dispatched by comments.js after async owner check
document.addEventListener('owner:detected', function() { document.addEventListener('owner:detected', function() {
var ownerStore = Alpine.store && Alpine.store('owner');
if (!ownerStore || !ownerStore.isOwner) return;
wireReplyButtons(); wireReplyButtons();
// Render threaded owner replies under matching webmention cards
var ownerReplies = ownerStore.replies || [];
ownerReplies.forEach(function(reply) {
var inReplyTo = reply['in-reply-to'];
if (!inReplyTo) return;
// Find the webmention card whose URL matches
var matchingLi = document.querySelector('.webmention-replies li[data-wm-url="' + CSS.escape(inReplyTo) + '"]');
if (!matchingLi) return;
var slot = matchingLi.querySelector('.wm-owner-reply-slot');
if (!slot) return;
// Build owner reply card
var replyCard = document.createElement('div');
replyCard.className = 'p-3 bg-surface-100 dark:bg-surface-900 rounded-lg border-l-2 border-amber-400 dark:border-amber-600';
var innerDiv = document.createElement('div');
innerDiv.className = 'flex items-start gap-2';
var avatar = document.createElement('div');
avatar.className = 'w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900 flex-shrink-0 flex items-center justify-center text-xs font-bold';
avatar.textContent = (reply.author && reply.author.name ? reply.author.name[0] : 'O').toUpperCase();
var contentArea = document.createElement('div');
contentArea.className = 'flex-1';
var headerRow = document.createElement('div');
headerRow.className = 'flex items-center gap-2 flex-wrap';
var nameSpan = document.createElement('span');
nameSpan.className = 'font-medium text-sm';
nameSpan.textContent = (reply.author && reply.author.name) || 'Owner';
var authorBadge = document.createElement('span');
authorBadge.className = 'inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full';
authorBadge.textContent = 'Author';
var timeEl = document.createElement('time');
timeEl.className = 'text-xs text-surface-600 dark:text-surface-400 font-mono';
timeEl.dateTime = reply.published || '';
timeEl.textContent = formatDate(reply.published);
headerRow.appendChild(nameSpan);
headerRow.appendChild(authorBadge);
headerRow.appendChild(timeEl);
var textDiv = document.createElement('div');
textDiv.className = 'mt-1 text-sm prose dark:prose-invert';
textDiv.textContent = (reply.content && reply.content.text) || '';
contentArea.appendChild(headerRow);
contentArea.appendChild(textDiv);
innerDiv.appendChild(avatar);
innerDiv.appendChild(contentArea);
replyCard.appendChild(innerDiv);
slot.appendChild(replyCard);
});
}); });
})(); })();