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:
@@ -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
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 #}
|
||||||
|
|||||||
Reference in New Issue
Block a user