feat: rewrite moderation UI with filter mode, fix sparse index bug

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.
This commit is contained in:
Ricardo
2026-02-23 23:11:28 +01:00
parent 9805cb9eff
commit 23fc8f4614
9 changed files with 350 additions and 83 deletions
+40
View File
@@ -1320,6 +1320,46 @@
background: var(--color-offset-variant); 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 Responsive
========================================================================== */ ========================================================================== */
+6
View File
@@ -34,6 +34,7 @@ import {
blockController, blockController,
unblockController, unblockController,
moderationController, moderationController,
filterModeController,
} from "./lib/controllers/moderation.js"; } from "./lib/controllers/moderation.js";
import { followersController } from "./lib/controllers/followers.js"; import { followersController } from "./lib/controllers/followers.js";
import { followingController } from "./lib/controllers/following.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/follow", followController(mp, this));
router.post("/admin/reader/unfollow", unfollowController(mp, this)); router.post("/admin/reader/unfollow", unfollowController(mp, this));
router.get("/admin/reader/moderation", moderationController(mp)); 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/mute", muteController(mp, this));
router.post("/admin/reader/unmute", unmuteController(mp, this)); router.post("/admin/reader/unmute", unmuteController(mp, this));
router.post("/admin/reader/block", blockController(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( this._collections.ap_muted.createIndex(
{ url: 1 }, { url: 1 },
{ unique: true, sparse: true, background: true }, { unique: true, sparse: true, background: true },
+48 -2
View File
@@ -10,6 +10,8 @@ import {
removeBlocked, removeBlocked,
getAllMuted, getAllMuted,
getAllBlocked, getAllBlocked,
getFilterMode,
setFilterMode,
} from "../storage/moderation.js"; } from "../storage/moderation.js";
/** /**
@@ -21,6 +23,7 @@ function getModerationCollections(request) {
ap_muted: application?.collections?.get("ap_muted"), ap_muted: application?.collections?.get("ap_muted"),
ap_blocked: application?.collections?.get("ap_blocked"), ap_blocked: application?.collections?.get("ap_blocked"),
ap_timeline: application?.collections?.get("ap_timeline"), 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 collections = getModerationCollections(request);
const csrfToken = getToken(request.session); const csrfToken = getToken(request.session);
const muted = await getAllMuted(collections); const [muted, blocked, filterMode] = await Promise.all([
const blocked = await getAllBlocked(collections); getAllMuted(collections),
getAllBlocked(collections),
getFilterMode(collections),
]);
const mutedActors = muted.filter((e) => e.url);
const mutedKeywords = muted.filter((e) => e.keyword);
response.render("activitypub-moderation", { response.render("activitypub-moderation", {
title: response.locals.__("activitypub.moderation.title"), title: response.locals.__("activitypub.moderation.title"),
muted, muted,
blocked, blocked,
mutedActors,
mutedKeywords,
filterMode,
csrfToken, csrfToken,
mountPath, 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.",
});
}
};
}
+43 -12
View File
@@ -16,6 +16,7 @@ import {
getMutedUrls, getMutedUrls,
getMutedKeywords, getMutedKeywords,
getBlockedUrls, getBlockedUrls,
getFilterMode,
} from "../storage/moderation.js"; } from "../storage/moderation.js";
// Re-export controllers from split modules for backward compatibility // Re-export controllers from split modules for backward compatibility
@@ -78,32 +79,62 @@ export function readerController(mountPath) {
const modCollections = { const modCollections = {
ap_muted: application?.collections?.get("ap_muted"), ap_muted: application?.collections?.get("ap_muted"),
ap_blocked: application?.collections?.get("ap_blocked"), ap_blocked: application?.collections?.get("ap_blocked"),
ap_profile: application?.collections?.get("ap_profile"),
}; };
const [mutedUrls, mutedKeywords, blockedUrls] = await Promise.all([ const [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
await Promise.all([
getMutedUrls(modCollections), getMutedUrls(modCollections),
getMutedKeywords(modCollections), getMutedKeywords(modCollections),
getBlockedUrls(modCollections), getBlockedUrls(modCollections),
getFilterMode(modCollections),
]); ]);
const hiddenUrls = new Set([...mutedUrls, ...blockedUrls]); 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) => { items = items.filter((item) => {
// Filter by author URL // Blocked actors are ALWAYS hidden
if (item.author?.url && hiddenUrls.has(item.author.url)) { if (item.author?.url && blockedSet.has(item.author.url)) {
return false; return false;
} }
// Filter by muted keywords in content // Check muted actor
if (mutedKeywords.length > 0 && item.content?.text) { const isMutedActor =
const lower = item.content.text.toLowerCase(); item.author?.url && mutedSet.has(item.author.url);
if ( // Check muted keywords against content, title, and summary
mutedKeywords.some((kw) => lower.includes(kw.toLowerCase())) let matchedKeyword = null;
) { if (mutedKeywords.length > 0) {
return false; 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; return true;
}); });
} }
+32 -5
View File
@@ -22,11 +22,11 @@ export async function addMuted(collections, { url, keyword }) {
throw new Error("Cannot mute both url and keyword in same entry"); throw new Error("Cannot mute both url and keyword in same entry");
} }
const entry = { // Only include the field that's set — avoids null values that conflict
url: url || null, // with sparse unique indexes
keyword: keyword || null, const entry = { mutedAt: new Date().toISOString() };
mutedAt: new Date().toISOString(), if (url) entry.url = url;
}; if (keyword) entry.keyword = keyword;
// Upsert to avoid duplicates // Upsert to avoid duplicates
const filter = url ? { url } : { keyword }; const filter = url ? { url } : { keyword };
@@ -178,3 +178,30 @@ export async function getAllBlocked(collections) {
const { ap_blocked } = collections; const { ap_blocked } = collections;
return await ap_blocked.find({}).toArray(); 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<string>} "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 } });
}
+8 -1
View File
@@ -130,7 +130,14 @@
"keywordPlaceholder": "Enter keyword or phrase…", "keywordPlaceholder": "Enter keyword or phrase…",
"addKeyword": "Add", "addKeyword": "Add",
"muteActor": "Mute", "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": { "compose": {
"title": "Compose reply", "title": "Compose reply",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "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.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",
+140 -51
View File
@@ -4,25 +4,40 @@
{% from "prose/macro.njk" import prose with context %} {% from "prose/macro.njk" import prose with context %}
{% block readercontent %} {% block readercontent %}
<div x-data="moderationPage()" data-mount-path="{{ mountPath }}" data-csrf-token="{{ csrfToken }}">
{# Filter mode toggle #}
<section class="ap-moderation__section">
<h2>{{ __("activitypub.moderation.filterModeTitle") }}</h2>
<p class="ap-moderation__hint">{{ __("activitypub.moderation.filterModeHint") }}</p>
<div class="ap-moderation__filter-toggle">
<label class="ap-moderation__radio">
<input type="radio" name="filterMode" value="hide"
{% if filterMode == "hide" %}checked{% endif %}
@change="setFilterMode('hide')">
<span>{{ __("activitypub.moderation.filterModeHide") }}</span>
</label>
<label class="ap-moderation__radio">
<input type="radio" name="filterMode" value="warn"
{% if filterMode == "warn" %}checked{% endif %}
@change="setFilterMode('warn')">
<span>{{ __("activitypub.moderation.filterModeWarn") }}</span>
</label>
</div>
</section>
{# Blocked actors #} {# Blocked actors #}
<section class="ap-moderation__section"> <section class="ap-moderation__section">
<h2>{{ __("activitypub.moderation.blockedTitle") }}</h2> <h2>{{ __("activitypub.moderation.blockedTitle") }}</h2>
{% if blocked.length > 0 %} {% if blocked.length > 0 %}
<ul class="ap-moderation__list"> <ul class="ap-moderation__list">
{% for entry in blocked %} {% for entry in blocked %}
<li class="ap-moderation__entry" <li class="ap-moderation__entry" data-url="{{ entry.url }}">
x-data="{ removing: false }">
<a href="{{ entry.url }}">{{ entry.url }}</a> <a href="{{ entry.url }}">{{ entry.url }}</a>
<button class="ap-moderation__remove" <button class="ap-moderation__remove"
:disabled="removing" @click="removeEntry($el, 'unblock', { url: $el.closest('li').dataset.url })">
@click=" {{ __("activitypub.moderation.unblock") }}
removing = true; </button>
fetch('{{ mountPath }}/admin/reader/unblock', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
body: JSON.stringify({ url: '{{ entry.url }}' })
}).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
">{{ __("activitypub.moderation.unblock") }}</button>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -38,19 +53,12 @@
{% if mutedActors | length > 0 %} {% if mutedActors | length > 0 %}
<ul class="ap-moderation__list"> <ul class="ap-moderation__list">
{% for entry in mutedActors %} {% for entry in mutedActors %}
<li class="ap-moderation__entry" <li class="ap-moderation__entry" data-url="{{ entry.url }}">
x-data="{ removing: false }">
<a href="{{ entry.url }}">{{ entry.url }}</a> <a href="{{ entry.url }}">{{ entry.url }}</a>
<button class="ap-moderation__remove" <button class="ap-moderation__remove"
:disabled="removing" @click="removeEntry($el, 'unmute', { url: $el.closest('li').dataset.url })">
@click=" {{ __("activitypub.moderation.unmute") }}
removing = true; </button>
fetch('{{ mountPath }}/admin/reader/unmute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
body: JSON.stringify({ url: '{{ entry.url }}' })
}).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
">{{ __("activitypub.moderation.unmute") }}</button>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -62,51 +70,132 @@
{# Muted keywords #} {# Muted keywords #}
<section class="ap-moderation__section"> <section class="ap-moderation__section">
<h2>{{ __("activitypub.moderation.mutedKeywordsTitle") }}</h2> <h2>{{ __("activitypub.moderation.mutedKeywordsTitle") }}</h2>
<ul class="ap-moderation__list" x-ref="keywordList">
{% set mutedKeywords = muted | selectattr("keyword") %} {% set mutedKeywords = muted | selectattr("keyword") %}
{% if mutedKeywords | length > 0 %}
<ul class="ap-moderation__list">
{% for entry in mutedKeywords %} {% for entry in mutedKeywords %}
<li class="ap-moderation__entry" <li class="ap-moderation__entry" data-keyword="{{ entry.keyword }}">
x-data="{ removing: false }"> <code x-text="$el.closest('li').dataset.keyword">{{ entry.keyword }}</code>
<code>{{ entry.keyword }}</code>
<button class="ap-moderation__remove" <button class="ap-moderation__remove"
:disabled="removing" @click="removeEntry($el, 'unmute', { keyword: $el.closest('li').dataset.keyword })">
@click=" {{ __("activitypub.moderation.unmute") }}
removing = true; </button>
fetch('{{ mountPath }}/admin/reader/unmute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
body: JSON.stringify({ keyword: '{{ entry.keyword }}' })
}).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
">{{ __("activitypub.moderation.unmute") }}</button>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% if not (mutedKeywords | length) %}
{{ prose({ text: __("activitypub.moderation.noMutedKeywords") }) }} <p class="ap-moderation__empty" x-ref="keywordEmpty">{{ __("activitypub.moderation.noMutedKeywords") }}</p>
{% endif %} {% endif %}
</section> </section>
{# Add keyword mute form #} {# Add keyword mute form #}
<section class="ap-moderation__section"> <section class="ap-moderation__section">
<h2>{{ __("activitypub.moderation.addKeywordTitle") }}</h2> <h2>{{ __("activitypub.moderation.addKeywordTitle") }}</h2>
<form class="ap-moderation__add-form" <form class="ap-moderation__add-form" @submit.prevent="addKeyword()">
x-data="{ keyword: '', submitting: false }" <input type="text" x-model="newKeyword"
@submit.prevent="
if (!keyword.trim()) return;
submitting = true;
fetch('{{ mountPath }}/admin/reader/mute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
body: JSON.stringify({ keyword: keyword.trim() })
}).then(r => r.json()).then(d => { if (d.success) location.reload(); submitting = false; }).catch(() => submitting = false);
">
<input type="text" x-model="keyword"
placeholder="{{ __('activitypub.moderation.keywordPlaceholder') }}" placeholder="{{ __('activitypub.moderation.keywordPlaceholder') }}"
class="ap-moderation__input"> class="ap-moderation__input"
x-ref="keywordInput">
<button type="submit" :disabled="submitting" class="ap-moderation__add-btn"> <button type="submit" :disabled="submitting" class="ap-moderation__add-btn">
{{ __("activitypub.moderation.addKeyword") }} {{ __("activitypub.moderation.addKeyword") }}
</button> </button>
</form> </form>
<p x-show="error" x-text="error" class="ap-moderation__error" x-cloak></p>
</section> </section>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('moderationPage', () => ({
newKeyword: '',
submitting: false,
error: '',
get mountPath() { return this.$root.dataset.mountPath; },
get csrfToken() { return this.$root.dataset.csrfToken; },
async addKeyword() {
const kw = this.newKeyword.trim();
if (!kw) return;
this.submitting = true;
this.error = '';
try {
const res = await fetch(this.mountPath + '/admin/reader/mute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken,
},
body: JSON.stringify({ keyword: kw }),
});
const data = await res.json();
if (data.success) {
// Add to list inline — no reload needed
const list = this.$refs.keywordList;
const li = document.createElement('li');
li.className = 'ap-moderation__entry';
li.dataset.keyword = kw;
const code = document.createElement('code');
code.textContent = kw;
const btn = document.createElement('button');
btn.className = 'ap-moderation__remove';
btn.textContent = 'Unmute';
btn.addEventListener('click', () => {
this.removeEntry(btn, 'unmute', { keyword: kw });
});
li.append(code, btn);
list.appendChild(li);
if (this.$refs.keywordEmpty) this.$refs.keywordEmpty.remove();
this.newKeyword = '';
this.$refs.keywordInput.focus();
} else {
this.error = data.error || 'Failed to add keyword';
}
} catch (e) {
this.error = 'Request failed';
}
this.submitting = false;
},
async removeEntry(el, action, payload) {
const li = el.closest('li');
if (!li) return;
el.disabled = true;
try {
const res = await fetch(this.mountPath + '/admin/reader/' + action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken,
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.success) {
li.remove();
} else {
el.disabled = false;
}
} catch {
el.disabled = false;
}
},
async setFilterMode(mode) {
try {
await fetch(this.mountPath + '/admin/reader/moderation/filter-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken,
},
body: JSON.stringify({ mode }),
});
} catch {
// Silently fail — radio will visually stay on selected
}
},
}));
});
</script>
{% endblock %} {% endblock %}
+22 -1
View File
@@ -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) %} {% 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 %} {% if hasCardContent or hasCardTitle or hasCardMedia %}
<article class="ap-card{% if item.type %} ap-card--{{ item.type }}{% endif %}{% if item.inReplyTo %} ap-card--reply{% endif %}"> <article class="ap-card{% if item.type %} ap-card--{{ item.type }}{% endif %}{% if item.inReplyTo %} ap-card--reply{% endif %}{% if item._moderated %} ap-card--moderated{% endif %}">
{# 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 %}
<div class="ap-card__moderation-cw" x-data="{ shown: false }">
<button @click="shown = !shown" class="ap-card__moderation-toggle">
<span x-show="!shown">🛡️ {{ modLabel }} — {{ __("activitypub.reader.showContent") }}</span>
<span x-show="shown" x-cloak>🛡️ {{ modLabel }} — {{ __("activitypub.reader.hideContent") }}</span>
</button>
<div x-show="shown" x-cloak>
{% endif %}
{# Boost header if this is a boosted post #} {# Boost header if this is a boosted post #}
{% if item.type == "boost" and item.boostedBy %} {% if item.type == "boost" and item.boostedBy %}
<div class="ap-card__boost"> <div class="ap-card__boost">
@@ -184,6 +200,11 @@
</a> </a>
<div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div> <div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div>
</footer> </footer>
{# Close moderation content warning wrapper #}
{% if item._moderated %}
</div>{# /x-show="shown" #}
</div>{# /ap-card__moderation-cw #}
{% endif %}
</article> </article>
{% endif %}{# end hasCardContent/hasCardTitle/hasCardMedia guard #} {% endif %}{# end hasCardContent/hasCardTitle/hasCardMedia guard #}