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);
|
||||
}
|
||||
|
||||
.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
|
||||
========================================================================== */
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+43
-12
@@ -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([
|
||||
const [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
|
||||
await Promise.all([
|
||||
getMutedUrls(modCollections),
|
||||
getMutedKeywords(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) => {
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<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…",
|
||||
"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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -4,25 +4,40 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% 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 #}
|
||||
<section class="ap-moderation__section">
|
||||
<h2>{{ __("activitypub.moderation.blockedTitle") }}</h2>
|
||||
{% if blocked.length > 0 %}
|
||||
<ul class="ap-moderation__list">
|
||||
{% for entry in blocked %}
|
||||
<li class="ap-moderation__entry"
|
||||
x-data="{ removing: false }">
|
||||
<li class="ap-moderation__entry" data-url="{{ entry.url }}">
|
||||
<a href="{{ entry.url }}">{{ entry.url }}</a>
|
||||
<button class="ap-moderation__remove"
|
||||
:disabled="removing"
|
||||
@click="
|
||||
removing = true;
|
||||
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>
|
||||
@click="removeEntry($el, 'unblock', { url: $el.closest('li').dataset.url })">
|
||||
{{ __("activitypub.moderation.unblock") }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -38,19 +53,12 @@
|
||||
{% if mutedActors | length > 0 %}
|
||||
<ul class="ap-moderation__list">
|
||||
{% for entry in mutedActors %}
|
||||
<li class="ap-moderation__entry"
|
||||
x-data="{ removing: false }">
|
||||
<li class="ap-moderation__entry" data-url="{{ entry.url }}">
|
||||
<a href="{{ entry.url }}">{{ entry.url }}</a>
|
||||
<button class="ap-moderation__remove"
|
||||
:disabled="removing"
|
||||
@click="
|
||||
removing = true;
|
||||
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>
|
||||
@click="removeEntry($el, 'unmute', { url: $el.closest('li').dataset.url })">
|
||||
{{ __("activitypub.moderation.unmute") }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -62,51 +70,132 @@
|
||||
{# Muted keywords #}
|
||||
<section class="ap-moderation__section">
|
||||
<h2>{{ __("activitypub.moderation.mutedKeywordsTitle") }}</h2>
|
||||
<ul class="ap-moderation__list" x-ref="keywordList">
|
||||
{% set mutedKeywords = muted | selectattr("keyword") %}
|
||||
{% if mutedKeywords | length > 0 %}
|
||||
<ul class="ap-moderation__list">
|
||||
{% for entry in mutedKeywords %}
|
||||
<li class="ap-moderation__entry"
|
||||
x-data="{ removing: false }">
|
||||
<code>{{ entry.keyword }}</code>
|
||||
<li class="ap-moderation__entry" data-keyword="{{ entry.keyword }}">
|
||||
<code x-text="$el.closest('li').dataset.keyword">{{ entry.keyword }}</code>
|
||||
<button class="ap-moderation__remove"
|
||||
:disabled="removing"
|
||||
@click="
|
||||
removing = true;
|
||||
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>
|
||||
@click="removeEntry($el, 'unmute', { keyword: $el.closest('li').dataset.keyword })">
|
||||
{{ __("activitypub.moderation.unmute") }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{{ prose({ text: __("activitypub.moderation.noMutedKeywords") }) }}
|
||||
{% if not (mutedKeywords | length) %}
|
||||
<p class="ap-moderation__empty" x-ref="keywordEmpty">{{ __("activitypub.moderation.noMutedKeywords") }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# Add keyword mute form #}
|
||||
<section class="ap-moderation__section">
|
||||
<h2>{{ __("activitypub.moderation.addKeywordTitle") }}</h2>
|
||||
<form class="ap-moderation__add-form"
|
||||
x-data="{ keyword: '', submitting: false }"
|
||||
@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"
|
||||
<form class="ap-moderation__add-form" @submit.prevent="addKeyword()">
|
||||
<input type="text" x-model="newKeyword"
|
||||
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">
|
||||
{{ __("activitypub.moderation.addKeyword") }}
|
||||
</button>
|
||||
</form>
|
||||
<p x-show="error" x-text="error" class="ap-moderation__error" x-cloak></p>
|
||||
</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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
<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 #}
|
||||
{% if item.type == "boost" and item.boostedBy %}
|
||||
<div class="ap-card__boost">
|
||||
@@ -184,6 +200,11 @@
|
||||
</a>
|
||||
<div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div>
|
||||
</footer>
|
||||
{# Close moderation content warning wrapper #}
|
||||
{% if item._moderated %}
|
||||
</div>{# /x-show="shown" #}
|
||||
</div>{# /ap-card__moderation-cw #}
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
{% endif %}{# end hasCardContent/hasCardTitle/hasCardMedia guard #}
|
||||
|
||||
Reference in New Issue
Block a user