From 53aaf5055749c070ef9268311011a9a45d07d14d Mon Sep 17 00:00:00 2001
From: svemagie <869694+svemagie@users.noreply.github.com>
Date: Mon, 16 Mar 2026 12:13:44 +0100
Subject: [PATCH] fix: improve fediverse detection and feed discovery
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- fetcher.js: try HTML discovery before
falling back to common feed paths (/feed, /rss.xml, etc.), fixing
subscriptions to sites like Substack, econsoc.mpifg.de, and others
whose feed URL is advertised via a link element but not at a
predictable path
- reader.js: extend detectProtocol to recognise more fediverse
domains (troet., social., hachyderm., infosec.exchange, chaos.social)
- reader.js: don't auto-check Mastodon syndication target for likes
and reposts — those are handled natively by the AP endpoint; use
service-name-aware target matching that works for any configured
Mastodon instance even if its domain isn't in the hardcoded list
Co-Authored-By: Claude Sonnet 4.6
---
lib/controllers/reader.js | 32 ++++++++++++++++++++++++++++----
lib/feeds/fetcher.js | 19 +++++++++++++++++--
2 files changed, 45 insertions(+), 6 deletions(-)
diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js
index 2da5ded..1f153a2 100644
--- a/lib/controllers/reader.js
+++ b/lib/controllers/reader.js
@@ -446,8 +446,11 @@ function detectProtocol(url) {
if (!url || typeof url !== "string") return "web";
const lower = url.toLowerCase();
if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere";
+ // Well-known fediverse software domain patterns
if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") ||
- lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.")) return "fediverse";
+ lower.includes("troet.") || lower.includes("social.") || lower.includes("pleroma.") ||
+ lower.includes("misskey.") || lower.includes("pixelfed.") || lower.includes("hachyderm.") ||
+ lower.includes("infosec.exchange") || lower.includes("chaos.social")) return "fediverse";
return "web";
}
@@ -510,15 +513,36 @@ export async function compose(request, response) {
? await getSyndicationTargets(application, token)
: [];
- // Auto-select syndication target based on interaction URL protocol
+ // Auto-select syndication target based on interaction URL protocol.
+ // Likes and reposts on fediverse are handled natively by the AP endpoint —
+ // never auto-check Mastodon for those action types.
+ const isLikeOrRepost = !!(likeOf || like || repostOf || repost);
const interactionUrl = ensureString(replyTo || reply || likeOf || like || repostOf || repost);
- if (interactionUrl && syndicationTargets.length > 0) {
+ if (interactionUrl && syndicationTargets.length > 0 && !isLikeOrRepost) {
const protocol = detectProtocol(interactionUrl);
+
+ // Build set of Mastodon instance hostnames from configured targets so we
+ // can match same-instance URLs even if not in the hardcoded pattern list.
+ const mastodonHostnames = new Set();
+ for (const t of syndicationTargets) {
+ if (t.service?.name?.toLowerCase() === "mastodon" && t.service?.url) {
+ try { mastodonHostnames.add(new URL(t.service.url).hostname.toLowerCase()); } catch { /* ignore */ }
+ }
+ }
+ let interactionHostname = "";
+ try { interactionHostname = new URL(interactionUrl).hostname.toLowerCase(); } catch { /* ignore */ }
+
for (const target of syndicationTargets) {
const targetId = (target.uid || target.name || "").toLowerCase();
+ // Identify a Mastodon target by service name (reliable) or legacy uid/name patterns
+ const isMastodonTarget =
+ target.service?.name?.toLowerCase() === "mastodon" ||
+ targetId.includes("mastodon") ||
+ targetId.includes("mstdn");
+
if (protocol === "atmosphere" && (targetId.includes("bluesky") || targetId.includes("bsky"))) {
target.checked = true;
- } else if (protocol === "fediverse" && (targetId.includes("mastodon") || targetId.includes("mstdn"))) {
+ } else if (isMastodonTarget && (protocol === "fediverse" || mastodonHostnames.has(interactionHostname))) {
target.checked = true;
}
}
diff --git a/lib/feeds/fetcher.js b/lib/feeds/fetcher.js
index dc8da13..8a66512 100644
--- a/lib/feeds/fetcher.js
+++ b/lib/feeds/fetcher.js
@@ -164,9 +164,24 @@ export async function fetchAndParseFeed(url, options = {}) {
// Check if we got a parseable feed
const feedType = detectFeedType(result.content, result.contentType);
- // If we got ActivityPub or unknown, try common feed paths
+ // If we got ActivityPub or unknown, try link-based discovery then common paths
if (feedType === "activitypub" || feedType === "unknown") {
- const fallbackFeed = await tryCommonFeedPaths(url, options);
+ // 1. link-based discovery from HTML: parse
+ let discoveredFeedUrl;
+ if (result.content) {
+ const { discoverFeeds } = await import("./hfeed.js");
+ const discovered = await discoverFeeds(result.content, url);
+ const rssOrAtom = discovered.find(
+ (f) => f.type === "rss" || f.type === "atom" || f.type === "jsonfeed",
+ );
+ if (rssOrAtom) discoveredFeedUrl = rssOrAtom.url;
+ }
+
+ // 2. Fall back to common feed paths (/feed, /rss.xml, etc.)
+ const fallbackFeed = discoveredFeedUrl
+ ? { url: discoveredFeedUrl }
+ : await tryCommonFeedPaths(url, options);
+
if (fallbackFeed) {
// Fetch and parse the discovered feed
const feedResult = await fetchFeed(fallbackFeed.url, options);