From 23fc8f4614c91d6625b9034a01a594f8df713a8d Mon Sep 17 00:00:00 2001 From: Ricardo Date: Mon, 23 Feb 2026 23:11:28 +0100 Subject: [PATCH] feat: rewrite moderation UI with filter mode, fix sparse index bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moderation page rewritten as single Alpine.js component with inline DOM updates instead of location.reload(). Added hide/warn filter mode toggle — warn mode shows muted items behind content warning instead of hiding. Expanded keyword matching to check content, titles, and summaries. Fixed MongoDB E11000 duplicate key error by dropping non-sparse indexes on startup and recreating with sparse:true. Storage layer no longer stores null url/keyword fields. --- assets/reader.css | 40 ++++++ index.js | 6 + lib/controllers/moderation.js | 50 +++++++- lib/controllers/reader.js | 63 +++++++--- lib/storage/moderation.js | 37 +++++- locales/en.json | 9 +- package.json | 2 +- views/activitypub-moderation.njk | 203 ++++++++++++++++++++++--------- views/partials/ap-item-card.njk | 23 +++- 9 files changed, 350 insertions(+), 83 deletions(-) diff --git a/assets/reader.css b/assets/reader.css index ba2540a..83491fd 100644 --- a/assets/reader.css +++ b/assets/reader.css @@ -1320,6 +1320,46 @@ background: var(--color-offset-variant); } +.ap-moderation__add-btn:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.ap-moderation__error { + color: var(--color-error); + font-size: var(--font-size-s); + margin-top: var(--space-xs); +} + +.ap-moderation__empty { + color: var(--color-on-offset); + font-size: var(--font-size-s); + font-style: italic; +} + +.ap-moderation__hint { + color: var(--color-on-offset); + font-size: var(--font-size-s); + margin-bottom: var(--space-s); +} + +.ap-moderation__filter-toggle { + display: flex; + gap: var(--space-m); +} + +.ap-moderation__radio { + align-items: center; + cursor: pointer; + display: flex; + gap: var(--space-xs); +} + +.ap-moderation__radio input { + accent-color: var(--color-primary); + cursor: pointer; +} + /* ========================================================================== Responsive ========================================================================== */ diff --git a/index.js b/index.js index 293aa3e..1d8b515 100644 --- a/index.js +++ b/index.js @@ -34,6 +34,7 @@ import { blockController, unblockController, moderationController, + filterModeController, } from "./lib/controllers/moderation.js"; import { followersController } from "./lib/controllers/followers.js"; import { followingController } from "./lib/controllers/following.js"; @@ -228,6 +229,7 @@ export default class ActivityPubEndpoint { router.post("/admin/reader/follow", followController(mp, this)); router.post("/admin/reader/unfollow", unfollowController(mp, this)); router.get("/admin/reader/moderation", moderationController(mp)); + router.post("/admin/reader/moderation/filter-mode", filterModeController(mp)); router.post("/admin/reader/mute", muteController(mp, this)); router.post("/admin/reader/unmute", unmuteController(mp, this)); router.post("/admin/reader/block", blockController(mp, this)); @@ -948,6 +950,10 @@ export default class ActivityPubEndpoint { ); } + // Drop non-sparse indexes if they exist (created by earlier versions), + // then recreate with sparse:true so multiple null values are allowed. + try { await this._collections.ap_muted.dropIndex("url_1"); } catch {} + try { await this._collections.ap_muted.dropIndex("keyword_1"); } catch {} this._collections.ap_muted.createIndex( { url: 1 }, { unique: true, sparse: true, background: true }, diff --git a/lib/controllers/moderation.js b/lib/controllers/moderation.js index 211be05..a56c004 100644 --- a/lib/controllers/moderation.js +++ b/lib/controllers/moderation.js @@ -10,6 +10,8 @@ import { removeBlocked, getAllMuted, getAllBlocked, + getFilterMode, + setFilterMode, } from "../storage/moderation.js"; /** @@ -21,6 +23,7 @@ function getModerationCollections(request) { ap_muted: application?.collections?.get("ap_muted"), ap_blocked: application?.collections?.get("ap_blocked"), ap_timeline: application?.collections?.get("ap_timeline"), + ap_profile: application?.collections?.get("ap_profile"), }; } @@ -287,13 +290,22 @@ export function moderationController(mountPath) { const collections = getModerationCollections(request); const csrfToken = getToken(request.session); - const muted = await getAllMuted(collections); - const blocked = await getAllBlocked(collections); + const [muted, blocked, filterMode] = await Promise.all([ + getAllMuted(collections), + getAllBlocked(collections), + getFilterMode(collections), + ]); + + const mutedActors = muted.filter((e) => e.url); + const mutedKeywords = muted.filter((e) => e.keyword); response.render("activitypub-moderation", { title: response.locals.__("activitypub.moderation.title"), muted, blocked, + mutedActors, + mutedKeywords, + filterMode, csrfToken, mountPath, }); @@ -302,3 +314,37 @@ export function moderationController(mountPath) { } }; } + +/** + * POST /admin/reader/moderation/filter-mode — Update filter mode. + */ +export function filterModeController(mountPath) { + return async (request, response, next) => { + try { + if (!validateToken(request)) { + return response.status(403).json({ + success: false, + error: "Invalid CSRF token", + }); + } + + const { mode } = request.body; + if (!mode || !["hide", "warn"].includes(mode)) { + return response.status(400).json({ + success: false, + error: 'Mode must be "hide" or "warn"', + }); + } + + const collections = getModerationCollections(request); + await setFilterMode(collections, mode); + + return response.json({ success: true, mode }); + } catch (error) { + return response.status(500).json({ + success: false, + error: "Operation failed. Please try again later.", + }); + } + }; +} diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index d51445c..874351f 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -16,6 +16,7 @@ import { getMutedUrls, getMutedKeywords, getBlockedUrls, + getFilterMode, } from "../storage/moderation.js"; // Re-export controllers from split modules for backward compatibility @@ -78,32 +79,62 @@ export function readerController(mountPath) { const modCollections = { ap_muted: application?.collections?.get("ap_muted"), ap_blocked: application?.collections?.get("ap_blocked"), + ap_profile: application?.collections?.get("ap_profile"), }; - const [mutedUrls, mutedKeywords, blockedUrls] = await Promise.all([ - getMutedUrls(modCollections), - getMutedKeywords(modCollections), - getBlockedUrls(modCollections), - ]); - const hiddenUrls = new Set([...mutedUrls, ...blockedUrls]); + const [mutedUrls, mutedKeywords, blockedUrls, filterMode] = + await Promise.all([ + getMutedUrls(modCollections), + getMutedKeywords(modCollections), + getBlockedUrls(modCollections), + getFilterMode(modCollections), + ]); + const blockedSet = new Set(blockedUrls); + const mutedSet = new Set(mutedUrls); - if (hiddenUrls.size > 0 || mutedKeywords.length > 0) { + if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) { items = items.filter((item) => { - // Filter by author URL - if (item.author?.url && hiddenUrls.has(item.author.url)) { + // Blocked actors are ALWAYS hidden + if (item.author?.url && blockedSet.has(item.author.url)) { return false; } - // Filter by muted keywords in content - if (mutedKeywords.length > 0 && item.content?.text) { - const lower = item.content.text.toLowerCase(); + // Check muted actor + const isMutedActor = + item.author?.url && mutedSet.has(item.author.url); - if ( - mutedKeywords.some((kw) => lower.includes(kw.toLowerCase())) - ) { - return false; + // Check muted keywords against content, title, and summary + let matchedKeyword = null; + if (mutedKeywords.length > 0) { + const searchable = [ + item.content?.text, + item.name, + item.summary, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + if (searchable) { + matchedKeyword = mutedKeywords.find((kw) => + searchable.includes(kw.toLowerCase()), + ); } } + if (isMutedActor || matchedKeyword) { + if (filterMode === "warn") { + // Mark for content warning instead of hiding + item._moderated = true; + item._moderationReason = isMutedActor + ? "muted_account" + : "muted_keyword"; + if (matchedKeyword) { + item._moderationKeyword = matchedKeyword; + } + return true; + } + return false; + } + return true; }); } diff --git a/lib/storage/moderation.js b/lib/storage/moderation.js index 3712633..f30dcec 100644 --- a/lib/storage/moderation.js +++ b/lib/storage/moderation.js @@ -22,11 +22,11 @@ export async function addMuted(collections, { url, keyword }) { throw new Error("Cannot mute both url and keyword in same entry"); } - const entry = { - url: url || null, - keyword: keyword || null, - mutedAt: new Date().toISOString(), - }; + // Only include the field that's set — avoids null values that conflict + // with sparse unique indexes + const entry = { mutedAt: new Date().toISOString() }; + if (url) entry.url = url; + if (keyword) entry.keyword = keyword; // Upsert to avoid duplicates const filter = url ? { url } : { keyword }; @@ -178,3 +178,30 @@ export async function getAllBlocked(collections) { const { ap_blocked } = collections; return await ap_blocked.find({}).toArray(); } + +/** + * Get moderation filter mode ("hide" or "warn"). + * "hide" removes filtered items from timeline entirely (default). + * "warn" shows them behind a content-warning toggle. + * Blocked actors are ALWAYS hidden regardless of mode. + * @param {object} collections - MongoDB collections (needs ap_profile) + * @returns {Promise} "hide" or "warn" + */ +export async function getFilterMode(collections) { + const { ap_profile } = collections; + if (!ap_profile) return "hide"; + const profile = await ap_profile.findOne({}); + return profile?.moderationFilterMode || "hide"; +} + +/** + * Set moderation filter mode. + * @param {object} collections - MongoDB collections (needs ap_profile) + * @param {string} mode - "hide" or "warn" + */ +export async function setFilterMode(collections, mode) { + const { ap_profile } = collections; + if (!ap_profile) return; + const valid = mode === "warn" ? "warn" : "hide"; + await ap_profile.updateOne({}, { $set: { moderationFilterMode: valid } }); +} diff --git a/locales/en.json b/locales/en.json index 6458948..0424366 100644 --- a/locales/en.json +++ b/locales/en.json @@ -130,7 +130,14 @@ "keywordPlaceholder": "Enter keyword or phrase…", "addKeyword": "Add", "muteActor": "Mute", - "blockActor": "Block" + "blockActor": "Block", + "filterModeTitle": "Filter mode", + "filterModeHint": "Choose how muted content is handled in your timeline. Blocked accounts are always hidden.", + "filterModeHide": "Hide — remove from timeline", + "filterModeWarn": "Warn — show behind content warning", + "cwMutedAccount": "Muted account", + "cwMutedKeyword": "Muted keyword:", + "cwFiltered": "Filtered content" }, "compose": { "title": "Compose reply", diff --git a/package.json b/package.json index 810b052..b51b9df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "2.0.12", + "version": "2.0.14", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-moderation.njk b/views/activitypub-moderation.njk index 986e93e..b8fb18d 100644 --- a/views/activitypub-moderation.njk +++ b/views/activitypub-moderation.njk @@ -4,25 +4,40 @@ {% from "prose/macro.njk" import prose with context %} {% block readercontent %} +
+ + {# Filter mode toggle #} +
+

{{ __("activitypub.moderation.filterModeTitle") }}

+

{{ __("activitypub.moderation.filterModeHint") }}

+
+ + +
+
+ {# Blocked actors #}

{{ __("activitypub.moderation.blockedTitle") }}

{% if blocked.length > 0 %}
    {% for entry in blocked %} -
  • +
  • {{ entry.url }} + @click="removeEntry($el, 'unblock', { url: $el.closest('li').dataset.url })"> + {{ __("activitypub.moderation.unblock") }} +
  • {% endfor %}
@@ -38,19 +53,12 @@ {% if mutedActors | length > 0 %}
    {% for entry in mutedActors %} -
  • +
  • {{ entry.url }} + @click="removeEntry($el, 'unmute', { url: $el.closest('li').dataset.url })"> + {{ __("activitypub.moderation.unmute") }} +
  • {% endfor %}
@@ -62,51 +70,132 @@ {# Muted keywords #}

{{ __("activitypub.moderation.mutedKeywordsTitle") }}

- {% set mutedKeywords = muted | selectattr("keyword") %} - {% if mutedKeywords | length > 0 %} -
    - {% for entry in mutedKeywords %} -
  • - {{ entry.keyword }} - -
  • - {% endfor %} -
- {% else %} - {{ prose({ text: __("activitypub.moderation.noMutedKeywords") }) }} +
    + {% set mutedKeywords = muted | selectattr("keyword") %} + {% for entry in mutedKeywords %} +
  • + {{ entry.keyword }} + +
  • + {% endfor %} +
+ {% if not (mutedKeywords | length) %} +

{{ __("activitypub.moderation.noMutedKeywords") }}

{% endif %}
{# Add keyword mute form #}

{{ __("activitypub.moderation.addKeywordTitle") }}

-
- + + class="ap-moderation__input" + x-ref="keywordInput">
+

+ +
+ + {% endblock %} diff --git a/views/partials/ap-item-card.njk b/views/partials/ap-item-card.njk index b73a5cc..f7c7961 100644 --- a/views/partials/ap-item-card.njk +++ b/views/partials/ap-item-card.njk @@ -6,7 +6,23 @@ {% set hasCardMedia = (item.photo and item.photo.length > 0) or (item.video and item.video.length > 0) or (item.audio and item.audio.length > 0) %} {% if hasCardContent or hasCardTitle or hasCardMedia %} -
+
+ {# Moderation content warning wrapper #} + {% if item._moderated %} + {% if item._moderationReason == "muted_account" %} + {% set modLabel = __("activitypub.moderation.cwMutedAccount") %} + {% elif item._moderationReason == "muted_keyword" and item._moderationKeyword %} + {% set modLabel = __("activitypub.moderation.cwMutedKeyword") + ' "' + item._moderationKeyword + '"' %} + {% else %} + {% set modLabel = __("activitypub.moderation.cwFiltered") %} + {% endif %} +
+ +
+ {% endif %} {# Boost header if this is a boosted post #} {% if item.type == "boost" and item.boostedBy %}
@@ -184,6 +200,11 @@
+ {# Close moderation content warning wrapper #} + {% if item._moderated %} +
{# /x-show="shown" #} +
{# /ap-card__moderation-cw #} + {% endif %}
{% endif %}{# end hasCardContent/hasCardTitle/hasCardMedia guard #}