diff --git a/lib/controllers/compose.js b/lib/controllers/compose.js index c160c06..74db1a3 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -146,12 +146,10 @@ export function composeController(mountPath, plugin) { ? await getSyndicationTargets(application, token) : []; - // Default-check only AP (Fedify) and Bluesky targets - // "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky - for (const target of syndicationTargets) { - const name = target.name || ""; - target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net"; - } + // Pre-check syndication targets based on their configured checked state // [patch] ap-compose-default-checked + for (const target of syndicationTargets) { // [patch] ap-compose-default-checked + target.defaultChecked = target.checked === true; // [patch] ap-compose-default-checked + } // [patch] ap-compose-default-checked const csrfToken = getToken(request.session); diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index 12c845e..3c5614a 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -23,6 +23,7 @@ import { Remove, Undo, Update, + View, // View imported } from "@fedify/fedify/vocab"; import { isServerBlocked } from "./storage/server-blocks.js"; @@ -352,5 +353,11 @@ export function registerInboxListeners(inboxChain, options) { actorUrl, rawJson: await flag.toJsonLd(), }); - }); + }) + // ── View (PeerTube watch) ───────────────────────────────────────────── + // PeerTube broadcasts View (WatchAction) activities to all followers + // whenever someone watches a video. Fedify has no built-in handler for + // this type, producing noisy "Unsupported activity type" log errors. + // Silently accept and discard. // PeerTube View handler + .on(View, async () => {}); } diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index 7492421..deb19c8 100644 --- a/lib/jf2-to-as2.js +++ b/lib/jf2-to-as2.js @@ -305,19 +305,33 @@ export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, opt // Reposts are always public — upstream @rmdes addressing if (postType === "repost") { - const repostOf = properties["repost-of"]; + const repostOf = Array.isArray(properties["repost-of"]) + ? properties["repost-of"][0] + : properties["repost-of"]; if (!repostOf) return null; const repostContent = properties.content?.html || properties.content || ""; if (!repostContent) { - // Pure repost — send as a native Announce (boost) so remote servers - // can display it as a boost of the original post. - return new Announce({ - actor: actorUri, - object: new URL(repostOf), - to: new URL("https://www.w3.org/ns/activitystreams#Public"), - }); + // Only send Announce if repost-of is an ActivityPub URL. + // Non-AP URLs (web articles) cannot be federated as a boost — fall + // through to Create(Note) which renders as "🔁 " on the fediverse. + if (await isApUrl(repostOf)) { // [patch] ap-repost-announce-fix + const actorPath = new URL(actorUrl).pathname; + const mp = actorPath.replace(/\/users\/[^/]+$/, ""); + const postRelPath = (properties.url || "") + .replace(publicationUrl.replace(/\/$/, ""), "") + .replace(/^\//, "") + .replace(/\/$/, ""); + const announceId = `${publicationUrl.replace(/\/$/, "")}${mp}/activities/boost/${postRelPath}`; + return new Announce({ + id: new URL(announceId), + actor: actorUri, + object: new URL(repostOf), + to: new URL("https://www.w3.org/ns/activitystreams#Public"), + cc: new URL(`${actorUrl.replace(/\/$/, "")}/followers`), + }); + } } - // Has commentary — fall through to Create(Note) so the text is federated. + // Has commentary or non-AP repost-of URL — fall through to Create(Note) so the text is federated. // The note content block below handles the "repost" post-type. } diff --git a/lib/mastodon/helpers/interactions.js b/lib/mastodon/helpers/interactions.js index a285dab..fba315d 100644 --- a/lib/mastodon/helpers/interactions.js +++ b/lib/mastodon/helpers/interactions.js @@ -55,9 +55,11 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl, }); if (recipient) { - await ctx.sendActivity({ identifier: handle }, recipient, like, { - orderingKey: targetUrl, - }); + try { // [patch] ap-interactions-send-guard + await ctx.sendActivity({ identifier: handle }, recipient, like, { + orderingKey: targetUrl, + }); + } catch { /* delivery failed — interaction still recorded locally */ } } if (interactions) { @@ -176,11 +178,13 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl, }); // Send to followers - await ctx.sendActivity({ identifier: handle }, "followers", announce, { - preferSharedInbox: true, - syncCollection: true, - orderingKey: targetUrl, - }); + try { // [patch] ap-interactions-send-guard + await ctx.sendActivity({ identifier: handle }, "followers", announce, { + preferSharedInbox: true, + syncCollection: true, + orderingKey: targetUrl, + }); + } catch { /* delivery failed — interaction still recorded locally */ } // Also send directly to the original post author (best-effort, 5 s cap) const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); diff --git a/lib/mastodon/helpers/resolve-account.js b/lib/mastodon/helpers/resolve-account.js index 25a3023..c8b1e45 100644 --- a/lib/mastodon/helpers/resolve-account.js +++ b/lib/mastodon/helpers/resolve-account.js @@ -69,24 +69,21 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect } catch { /* ignore */ } // Get collection counts (followers, following, outbox) — with 5 s timeout each - const withTimeout = (promise, ms = 5000) => - Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]); + const withTimeout = (promise, ms = 5000) => { // [patch] ap-resolve-account-timeout-safe + const abort = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)); + promise.catch(() => {}); // suppress unhandled rejection if timeout settles first + return Promise.race([promise, abort]); + }; - let followersCount = 0; - let followingCount = 0; - let statusesCount = 0; - try { - const followers = await withTimeout(actor.getFollowers()); - if (followers?.totalItems != null) followersCount = followers.totalItems; - } catch { /* ignore */ } - try { - const following = await withTimeout(actor.getFollowing()); - if (following?.totalItems != null) followingCount = following.totalItems; - } catch { /* ignore */ } - try { - const outbox = await withTimeout(actor.getOutbox()); - if (outbox?.totalItems != null) statusesCount = outbox.totalItems; - } catch { /* ignore */ } + // Fetch collection counts in parallel (max 5 s each) [patch] ap-resolve-account-timeout-safe + const [followersResult, followingResult, outboxResult] = await Promise.allSettled([ + withTimeout(actor.getFollowers()), + withTimeout(actor.getFollowing()), + withTimeout(actor.getOutbox()), + ]); + const followersCount = followersResult.status === "fulfilled" && followersResult.value?.totalItems != null ? followersResult.value.totalItems : 0; + const followingCount = followingResult.status === "fulfilled" && followingResult.value?.totalItems != null ? followingResult.value.totalItems : 0; + const statusesCount = outboxResult.status === "fulfilled" && outboxResult.value?.totalItems != null ? outboxResult.value.totalItems : 0; // Get published/created date — normalize to UTC ISO so clients display it correctly. // Temporal.Instant.toString() preserves the original timezone offset; @@ -141,13 +138,14 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect }); // Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts + // [patch] ap-actor-cache-await if (collections?.ap_actor_cache && actorUrl) { const hashId = remoteActorId(actorUrl); - collections.ap_actor_cache.updateOne( + await collections.ap_actor_cache.updateOne( { _id: hashId }, { $set: { actorUrl, updatedAt: new Date() } }, { upsert: true }, - ).catch(() => {}); // fire-and-forget, non-fatal + ).catch(() => {}); // non-fatal, but now awaited so entry exists before response } return account; diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index 81951b7..ed5d3eb 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -456,15 +456,23 @@ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read", let bookmarkedIds = new Set(); if (req.mastodonToken && collections.ap_interactions) { - const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean)); + const urlToUid = new Map(); // [patch] ap-interactions-accounts-uid + for (const i of items) { + if (i.uid) { + urlToUid.set(i.uid, i.uid); + if (i.url && i.url !== i.uid) urlToUid.set(i.url, i.uid); + } + } + const lookupUrls = [...urlToUid.keys()]; if (lookupUrls.length > 0) { const interactions = await collections.ap_interactions .find({ objectUrl: { $in: lookupUrls } }) .toArray(); for (const ix of interactions) { - if (ix.type === "like") favouritedIds.add(ix.objectUrl); - else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl); - else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl); + const uid = urlToUid.get(ix.objectUrl) || ix.objectUrl; + if (ix.type === "like") favouritedIds.add(uid); + else if (ix.type === "boost") rebloggedIds.add(uid); + else if (ix.type === "bookmark") bookmarkedIds.add(uid); } } } diff --git a/lib/mastodon/routes/notifications.js b/lib/mastodon/routes/notifications.js index 7dd4e8e..2f4d0e5 100644 --- a/lib/mastodon/routes/notifications.js +++ b/lib/mastodon/routes/notifications.js @@ -207,10 +207,10 @@ function resolveInternalTypes(mastodonTypes) { async function batchFetchStatuses(collections, notifications) { const statusMap = new Map(); - const targetUrls = [ + const targetUrls = [ // [patch] ap-notifications-status-lookup ...new Set( notifications - .map((n) => n.targetUrl) + .flatMap((n) => [n.targetUrl, n.url]) .filter(Boolean), ), ]; diff --git a/lib/syndicator.js b/lib/syndicator.js index 9a39753..1dc9a97 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -41,6 +41,20 @@ export function createSyndicator(plugin) { return undefined; } + // Dedup: skip re-federation if we've already sent an activity for this URL. // [patch] ap-syndicate-dedup + // ap_activities is the authoritative record of "already federated". + try { + const existingActivity = await plugin._collections.ap_activities?.findOne({ + direction: "outbound", + type: { $in: ["Create", "Announce", "Update"] }, + objectUrl: properties.url, + }); + if (existingActivity) { + console.info(`[ActivityPub] Skipping duplicate syndication for ${properties.url} — already sent (${existingActivity.type})`); + return properties.url || undefined; + } + } catch { /* DB unavailable — proceed */ } + try { const actorUrl = plugin._getActorUrl(); const handle = plugin.options.actor.handle; diff --git a/lib/timeline-cleanup.js b/lib/timeline-cleanup.js index cc88e7a..b68a9ac 100644 --- a/lib/timeline-cleanup.js +++ b/lib/timeline-cleanup.js @@ -53,16 +53,30 @@ export async function cleanupTimeline(collections, retentionLimit) { const removedUids = toDelete.map((item) => item.uid).filter(Boolean); + // Preserve items the user has interacted with (liked, bookmarked, boosted). // [patch] ap-interactions-cleanup-preserve + // Deleting them would silently remove entries from the Favourites/Bookmarks pages. + let interactedUids = new Set(); + if (removedUids.length > 0 && collections.ap_interactions) { + const interacted = await collections.ap_interactions.distinct("objectUrl"); + interactedUids = new Set(interacted); + } + const itemsToDelete = toDelete.filter((item) => !interactedUids.has(item.uid)); + const uidsToDelete = itemsToDelete.map((item) => item.uid).filter(Boolean); + + if (!itemsToDelete.length) { + return { removed: 0, interactionsRemoved: 0 }; + } + // Delete old timeline items by UID const deleteResult = await collections.ap_timeline.deleteMany({ - _id: { $in: toDelete.map((item) => item._id) }, + _id: { $in: itemsToDelete.map((item) => item._id) }, }); // Clean up stale interactions for removed items let interactionsRemoved = 0; - if (removedUids.length > 0 && collections.ap_interactions) { + if (uidsToDelete.length > 0 && collections.ap_interactions) { const interactionResult = await collections.ap_interactions.deleteMany({ - objectUrl: { $in: removedUids }, + objectUrl: { $in: uidsToDelete }, }); interactionsRemoved = interactionResult.deletedCount || 0; }