@rmdes/indiekit-endpoint-activitypub 2.0.12 → 2.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/reader.css CHANGED
@@ -1320,6 +1320,46 @@
1320
1320
  background: var(--color-offset-variant);
1321
1321
  }
1322
1322
 
1323
+ .ap-moderation__add-btn:disabled {
1324
+ cursor: not-allowed;
1325
+ opacity: 0.5;
1326
+ }
1327
+
1328
+ .ap-moderation__error {
1329
+ color: var(--color-error);
1330
+ font-size: var(--font-size-s);
1331
+ margin-top: var(--space-xs);
1332
+ }
1333
+
1334
+ .ap-moderation__empty {
1335
+ color: var(--color-on-offset);
1336
+ font-size: var(--font-size-s);
1337
+ font-style: italic;
1338
+ }
1339
+
1340
+ .ap-moderation__hint {
1341
+ color: var(--color-on-offset);
1342
+ font-size: var(--font-size-s);
1343
+ margin-bottom: var(--space-s);
1344
+ }
1345
+
1346
+ .ap-moderation__filter-toggle {
1347
+ display: flex;
1348
+ gap: var(--space-m);
1349
+ }
1350
+
1351
+ .ap-moderation__radio {
1352
+ align-items: center;
1353
+ cursor: pointer;
1354
+ display: flex;
1355
+ gap: var(--space-xs);
1356
+ }
1357
+
1358
+ .ap-moderation__radio input {
1359
+ accent-color: var(--color-primary);
1360
+ cursor: pointer;
1361
+ }
1362
+
1323
1363
  /* ==========================================================================
1324
1364
  Responsive
1325
1365
  ========================================================================== */
package/index.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  blockController,
35
35
  unblockController,
36
36
  moderationController,
37
+ filterModeController,
37
38
  } from "./lib/controllers/moderation.js";
38
39
  import { followersController } from "./lib/controllers/followers.js";
39
40
  import { followingController } from "./lib/controllers/following.js";
@@ -228,6 +229,7 @@ export default class ActivityPubEndpoint {
228
229
  router.post("/admin/reader/follow", followController(mp, this));
229
230
  router.post("/admin/reader/unfollow", unfollowController(mp, this));
230
231
  router.get("/admin/reader/moderation", moderationController(mp));
232
+ router.post("/admin/reader/moderation/filter-mode", filterModeController(mp));
231
233
  router.post("/admin/reader/mute", muteController(mp, this));
232
234
  router.post("/admin/reader/unmute", unmuteController(mp, this));
233
235
  router.post("/admin/reader/block", blockController(mp, this));
@@ -948,6 +950,10 @@ export default class ActivityPubEndpoint {
948
950
  );
949
951
  }
950
952
 
953
+ // Drop non-sparse indexes if they exist (created by earlier versions),
954
+ // then recreate with sparse:true so multiple null values are allowed.
955
+ try { await this._collections.ap_muted.dropIndex("url_1"); } catch {}
956
+ try { await this._collections.ap_muted.dropIndex("keyword_1"); } catch {}
951
957
  this._collections.ap_muted.createIndex(
952
958
  { url: 1 },
953
959
  { unique: true, sparse: true, background: true },
@@ -10,6 +10,8 @@ import {
10
10
  removeBlocked,
11
11
  getAllMuted,
12
12
  getAllBlocked,
13
+ getFilterMode,
14
+ setFilterMode,
13
15
  } from "../storage/moderation.js";
14
16
 
15
17
  /**
@@ -21,6 +23,7 @@ function getModerationCollections(request) {
21
23
  ap_muted: application?.collections?.get("ap_muted"),
22
24
  ap_blocked: application?.collections?.get("ap_blocked"),
23
25
  ap_timeline: application?.collections?.get("ap_timeline"),
26
+ ap_profile: application?.collections?.get("ap_profile"),
24
27
  };
25
28
  }
26
29
 
@@ -287,13 +290,22 @@ export function moderationController(mountPath) {
287
290
  const collections = getModerationCollections(request);
288
291
  const csrfToken = getToken(request.session);
289
292
 
290
- const muted = await getAllMuted(collections);
291
- const blocked = await getAllBlocked(collections);
293
+ const [muted, blocked, filterMode] = await Promise.all([
294
+ getAllMuted(collections),
295
+ getAllBlocked(collections),
296
+ getFilterMode(collections),
297
+ ]);
298
+
299
+ const mutedActors = muted.filter((e) => e.url);
300
+ const mutedKeywords = muted.filter((e) => e.keyword);
292
301
 
293
302
  response.render("activitypub-moderation", {
294
303
  title: response.locals.__("activitypub.moderation.title"),
295
304
  muted,
296
305
  blocked,
306
+ mutedActors,
307
+ mutedKeywords,
308
+ filterMode,
297
309
  csrfToken,
298
310
  mountPath,
299
311
  });
@@ -302,3 +314,37 @@ export function moderationController(mountPath) {
302
314
  }
303
315
  };
304
316
  }
317
+
318
+ /**
319
+ * POST /admin/reader/moderation/filter-mode — Update filter mode.
320
+ */
321
+ export function filterModeController(mountPath) {
322
+ return async (request, response, next) => {
323
+ try {
324
+ if (!validateToken(request)) {
325
+ return response.status(403).json({
326
+ success: false,
327
+ error: "Invalid CSRF token",
328
+ });
329
+ }
330
+
331
+ const { mode } = request.body;
332
+ if (!mode || !["hide", "warn"].includes(mode)) {
333
+ return response.status(400).json({
334
+ success: false,
335
+ error: 'Mode must be "hide" or "warn"',
336
+ });
337
+ }
338
+
339
+ const collections = getModerationCollections(request);
340
+ await setFilterMode(collections, mode);
341
+
342
+ return response.json({ success: true, mode });
343
+ } catch (error) {
344
+ return response.status(500).json({
345
+ success: false,
346
+ error: "Operation failed. Please try again later.",
347
+ });
348
+ }
349
+ };
350
+ }
@@ -16,6 +16,7 @@ import {
16
16
  getMutedUrls,
17
17
  getMutedKeywords,
18
18
  getBlockedUrls,
19
+ getFilterMode,
19
20
  } from "../storage/moderation.js";
20
21
 
21
22
  // Re-export controllers from split modules for backward compatibility
@@ -78,30 +79,57 @@ export function readerController(mountPath) {
78
79
  const modCollections = {
79
80
  ap_muted: application?.collections?.get("ap_muted"),
80
81
  ap_blocked: application?.collections?.get("ap_blocked"),
82
+ ap_profile: application?.collections?.get("ap_profile"),
81
83
  };
82
- const [mutedUrls, mutedKeywords, blockedUrls] = await Promise.all([
83
- getMutedUrls(modCollections),
84
- getMutedKeywords(modCollections),
85
- getBlockedUrls(modCollections),
86
- ]);
87
- const hiddenUrls = new Set([...mutedUrls, ...blockedUrls]);
88
-
89
- if (hiddenUrls.size > 0 || mutedKeywords.length > 0) {
84
+ const [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
85
+ await Promise.all([
86
+ getMutedUrls(modCollections),
87
+ getMutedKeywords(modCollections),
88
+ getBlockedUrls(modCollections),
89
+ getFilterMode(modCollections),
90
+ ]);
91
+ const blockedSet = new Set(blockedUrls);
92
+ const mutedSet = new Set(mutedUrls);
93
+
94
+ if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
90
95
  items = items.filter((item) => {
91
- // Filter by author URL
92
- if (item.author?.url && hiddenUrls.has(item.author.url)) {
96
+ // Blocked actors are ALWAYS hidden
97
+ if (item.author?.url && blockedSet.has(item.author.url)) {
93
98
  return false;
94
99
  }
95
100
 
96
- // Filter by muted keywords in content
97
- if (mutedKeywords.length > 0 && item.content?.text) {
98
- const lower = item.content.text.toLowerCase();
101
+ // Check muted actor
102
+ const isMutedActor =
103
+ item.author?.url && mutedSet.has(item.author.url);
104
+
105
+ // Check muted keywords against content, title, and summary
106
+ let matchedKeyword = null;
107
+ if (mutedKeywords.length > 0) {
108
+ const searchable = [
109
+ item.content?.text,
110
+ item.name,
111
+ item.summary,
112
+ ]
113
+ .filter(Boolean)
114
+ .join(" ")
115
+ .toLowerCase();
116
+ if (searchable) {
117
+ matchedKeyword = mutedKeywords.find((kw) =>
118
+ searchable.includes(kw.toLowerCase()),
119
+ );
120
+ }
121
+ }
99
122
 
100
- if (
101
- mutedKeywords.some((kw) => lower.includes(kw.toLowerCase()))
102
- ) {
103
- return false;
123
+ if (isMutedActor || matchedKeyword) {
124
+ if (filterMode === "warn") {
125
+ // Mark for content warning instead of hiding
126
+ item._moderated = true;
127
+ item._moderationReason = isMutedActor
128
+ ? "muted_account"
129
+ : `muted_keyword:${matchedKeyword}`;
130
+ return true;
104
131
  }
132
+ return false;
105
133
  }
106
134
 
107
135
  return true;
@@ -22,11 +22,11 @@ export async function addMuted(collections, { url, keyword }) {
22
22
  throw new Error("Cannot mute both url and keyword in same entry");
23
23
  }
24
24
 
25
- const entry = {
26
- url: url || null,
27
- keyword: keyword || null,
28
- mutedAt: new Date().toISOString(),
29
- };
25
+ // Only include the field that's set — avoids null values that conflict
26
+ // with sparse unique indexes
27
+ const entry = { mutedAt: new Date().toISOString() };
28
+ if (url) entry.url = url;
29
+ if (keyword) entry.keyword = keyword;
30
30
 
31
31
  // Upsert to avoid duplicates
32
32
  const filter = url ? { url } : { keyword };
@@ -178,3 +178,30 @@ export async function getAllBlocked(collections) {
178
178
  const { ap_blocked } = collections;
179
179
  return await ap_blocked.find({}).toArray();
180
180
  }
181
+
182
+ /**
183
+ * Get moderation filter mode ("hide" or "warn").
184
+ * "hide" removes filtered items from timeline entirely (default).
185
+ * "warn" shows them behind a content-warning toggle.
186
+ * Blocked actors are ALWAYS hidden regardless of mode.
187
+ * @param {object} collections - MongoDB collections (needs ap_profile)
188
+ * @returns {Promise<string>} "hide" or "warn"
189
+ */
190
+ export async function getFilterMode(collections) {
191
+ const { ap_profile } = collections;
192
+ if (!ap_profile) return "hide";
193
+ const profile = await ap_profile.findOne({});
194
+ return profile?.moderationFilterMode || "hide";
195
+ }
196
+
197
+ /**
198
+ * Set moderation filter mode.
199
+ * @param {object} collections - MongoDB collections (needs ap_profile)
200
+ * @param {string} mode - "hide" or "warn"
201
+ */
202
+ export async function setFilterMode(collections, mode) {
203
+ const { ap_profile } = collections;
204
+ if (!ap_profile) return;
205
+ const valid = mode === "warn" ? "warn" : "hide";
206
+ await ap_profile.updateOne({}, { $set: { moderationFilterMode: valid } });
207
+ }
package/locales/en.json CHANGED
@@ -130,7 +130,14 @@
130
130
  "keywordPlaceholder": "Enter keyword or phrase…",
131
131
  "addKeyword": "Add",
132
132
  "muteActor": "Mute",
133
- "blockActor": "Block"
133
+ "blockActor": "Block",
134
+ "filterModeTitle": "Filter mode",
135
+ "filterModeHint": "Choose how muted content is handled in your timeline. Blocked accounts are always hidden.",
136
+ "filterModeHide": "Hide — remove from timeline",
137
+ "filterModeWarn": "Warn — show behind content warning",
138
+ "cwMutedAccount": "Muted account",
139
+ "cwMutedKeyword": "Muted keyword:",
140
+ "cwFiltered": "Filtered content"
134
141
  },
135
142
  "compose": {
136
143
  "title": "Compose reply",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -4,25 +4,40 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block readercontent %}
7
+ <div x-data="moderationPage()" data-mount-path="{{ mountPath }}" data-csrf-token="{{ csrfToken }}">
8
+
9
+ {# Filter mode toggle #}
10
+ <section class="ap-moderation__section">
11
+ <h2>{{ __("activitypub.moderation.filterModeTitle") }}</h2>
12
+ <p class="ap-moderation__hint">{{ __("activitypub.moderation.filterModeHint") }}</p>
13
+ <div class="ap-moderation__filter-toggle">
14
+ <label class="ap-moderation__radio">
15
+ <input type="radio" name="filterMode" value="hide"
16
+ {% if filterMode == "hide" %}checked{% endif %}
17
+ @change="setFilterMode('hide')">
18
+ <span>{{ __("activitypub.moderation.filterModeHide") }}</span>
19
+ </label>
20
+ <label class="ap-moderation__radio">
21
+ <input type="radio" name="filterMode" value="warn"
22
+ {% if filterMode == "warn" %}checked{% endif %}
23
+ @change="setFilterMode('warn')">
24
+ <span>{{ __("activitypub.moderation.filterModeWarn") }}</span>
25
+ </label>
26
+ </div>
27
+ </section>
28
+
7
29
  {# Blocked actors #}
8
30
  <section class="ap-moderation__section">
9
31
  <h2>{{ __("activitypub.moderation.blockedTitle") }}</h2>
10
32
  {% if blocked.length > 0 %}
11
33
  <ul class="ap-moderation__list">
12
34
  {% for entry in blocked %}
13
- <li class="ap-moderation__entry"
14
- x-data="{ removing: false }">
35
+ <li class="ap-moderation__entry" data-url="{{ entry.url }}">
15
36
  <a href="{{ entry.url }}">{{ entry.url }}</a>
16
37
  <button class="ap-moderation__remove"
17
- :disabled="removing"
18
- @click="
19
- removing = true;
20
- fetch('{{ mountPath }}/admin/reader/unblock', {
21
- method: 'POST',
22
- headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
23
- body: JSON.stringify({ url: '{{ entry.url }}' })
24
- }).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
25
- ">{{ __("activitypub.moderation.unblock") }}</button>
38
+ @click="removeEntry($el, 'unblock', { url: $el.closest('li').dataset.url })">
39
+ {{ __("activitypub.moderation.unblock") }}
40
+ </button>
26
41
  </li>
27
42
  {% endfor %}
28
43
  </ul>
@@ -38,19 +53,12 @@
38
53
  {% if mutedActors | length > 0 %}
39
54
  <ul class="ap-moderation__list">
40
55
  {% for entry in mutedActors %}
41
- <li class="ap-moderation__entry"
42
- x-data="{ removing: false }">
56
+ <li class="ap-moderation__entry" data-url="{{ entry.url }}">
43
57
  <a href="{{ entry.url }}">{{ entry.url }}</a>
44
58
  <button class="ap-moderation__remove"
45
- :disabled="removing"
46
- @click="
47
- removing = true;
48
- fetch('{{ mountPath }}/admin/reader/unmute', {
49
- method: 'POST',
50
- headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
51
- body: JSON.stringify({ url: '{{ entry.url }}' })
52
- }).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
53
- ">{{ __("activitypub.moderation.unmute") }}</button>
59
+ @click="removeEntry($el, 'unmute', { url: $el.closest('li').dataset.url })">
60
+ {{ __("activitypub.moderation.unmute") }}
61
+ </button>
54
62
  </li>
55
63
  {% endfor %}
56
64
  </ul>
@@ -62,51 +70,132 @@
62
70
  {# Muted keywords #}
63
71
  <section class="ap-moderation__section">
64
72
  <h2>{{ __("activitypub.moderation.mutedKeywordsTitle") }}</h2>
65
- {% set mutedKeywords = muted | selectattr("keyword") %}
66
- {% if mutedKeywords | length > 0 %}
67
- <ul class="ap-moderation__list">
68
- {% for entry in mutedKeywords %}
69
- <li class="ap-moderation__entry"
70
- x-data="{ removing: false }">
71
- <code>{{ entry.keyword }}</code>
72
- <button class="ap-moderation__remove"
73
- :disabled="removing"
74
- @click="
75
- removing = true;
76
- fetch('{{ mountPath }}/admin/reader/unmute', {
77
- method: 'POST',
78
- headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
79
- body: JSON.stringify({ keyword: '{{ entry.keyword }}' })
80
- }).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
81
- ">{{ __("activitypub.moderation.unmute") }}</button>
82
- </li>
83
- {% endfor %}
84
- </ul>
85
- {% else %}
86
- {{ prose({ text: __("activitypub.moderation.noMutedKeywords") }) }}
73
+ <ul class="ap-moderation__list" x-ref="keywordList">
74
+ {% set mutedKeywords = muted | selectattr("keyword") %}
75
+ {% for entry in mutedKeywords %}
76
+ <li class="ap-moderation__entry" data-keyword="{{ entry.keyword }}">
77
+ <code x-text="$el.closest('li').dataset.keyword">{{ entry.keyword }}</code>
78
+ <button class="ap-moderation__remove"
79
+ @click="removeEntry($el, 'unmute', { keyword: $el.closest('li').dataset.keyword })">
80
+ {{ __("activitypub.moderation.unmute") }}
81
+ </button>
82
+ </li>
83
+ {% endfor %}
84
+ </ul>
85
+ {% if not (mutedKeywords | length) %}
86
+ <p class="ap-moderation__empty" x-ref="keywordEmpty">{{ __("activitypub.moderation.noMutedKeywords") }}</p>
87
87
  {% endif %}
88
88
  </section>
89
89
 
90
90
  {# Add keyword mute form #}
91
91
  <section class="ap-moderation__section">
92
92
  <h2>{{ __("activitypub.moderation.addKeywordTitle") }}</h2>
93
- <form class="ap-moderation__add-form"
94
- x-data="{ keyword: '', submitting: false }"
95
- @submit.prevent="
96
- if (!keyword.trim()) return;
97
- submitting = true;
98
- fetch('{{ mountPath }}/admin/reader/mute', {
99
- method: 'POST',
100
- headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
101
- body: JSON.stringify({ keyword: keyword.trim() })
102
- }).then(r => r.json()).then(d => { if (d.success) location.reload(); submitting = false; }).catch(() => submitting = false);
103
- ">
104
- <input type="text" x-model="keyword"
93
+ <form class="ap-moderation__add-form" @submit.prevent="addKeyword()">
94
+ <input type="text" x-model="newKeyword"
105
95
  placeholder="{{ __('activitypub.moderation.keywordPlaceholder') }}"
106
- class="ap-moderation__input">
96
+ class="ap-moderation__input"
97
+ x-ref="keywordInput">
107
98
  <button type="submit" :disabled="submitting" class="ap-moderation__add-btn">
108
99
  {{ __("activitypub.moderation.addKeyword") }}
109
100
  </button>
110
101
  </form>
102
+ <p x-show="error" x-text="error" class="ap-moderation__error" x-cloak></p>
111
103
  </section>
104
+
105
+ </div>
106
+
107
+ <script>
108
+ document.addEventListener('alpine:init', () => {
109
+ Alpine.data('moderationPage', () => ({
110
+ newKeyword: '',
111
+ submitting: false,
112
+ error: '',
113
+
114
+ get mountPath() { return this.$root.dataset.mountPath; },
115
+ get csrfToken() { return this.$root.dataset.csrfToken; },
116
+
117
+ async addKeyword() {
118
+ const kw = this.newKeyword.trim();
119
+ if (!kw) return;
120
+ this.submitting = true;
121
+ this.error = '';
122
+ try {
123
+ const res = await fetch(this.mountPath + '/admin/reader/mute', {
124
+ method: 'POST',
125
+ headers: {
126
+ 'Content-Type': 'application/json',
127
+ 'X-CSRF-Token': this.csrfToken,
128
+ },
129
+ body: JSON.stringify({ keyword: kw }),
130
+ });
131
+ const data = await res.json();
132
+ if (data.success) {
133
+ // Add to list inline — no reload needed
134
+ const list = this.$refs.keywordList;
135
+ const li = document.createElement('li');
136
+ li.className = 'ap-moderation__entry';
137
+ li.dataset.keyword = kw;
138
+ const code = document.createElement('code');
139
+ code.textContent = kw;
140
+ const btn = document.createElement('button');
141
+ btn.className = 'ap-moderation__remove';
142
+ btn.textContent = 'Unmute';
143
+ btn.addEventListener('click', () => {
144
+ this.removeEntry(btn, 'unmute', { keyword: kw });
145
+ });
146
+ li.append(code, btn);
147
+ list.appendChild(li);
148
+ if (this.$refs.keywordEmpty) this.$refs.keywordEmpty.remove();
149
+ this.newKeyword = '';
150
+ this.$refs.keywordInput.focus();
151
+ } else {
152
+ this.error = data.error || 'Failed to add keyword';
153
+ }
154
+ } catch (e) {
155
+ this.error = 'Request failed';
156
+ }
157
+ this.submitting = false;
158
+ },
159
+
160
+ async removeEntry(el, action, payload) {
161
+ const li = el.closest('li');
162
+ if (!li) return;
163
+ el.disabled = true;
164
+ try {
165
+ const res = await fetch(this.mountPath + '/admin/reader/' + action, {
166
+ method: 'POST',
167
+ headers: {
168
+ 'Content-Type': 'application/json',
169
+ 'X-CSRF-Token': this.csrfToken,
170
+ },
171
+ body: JSON.stringify(payload),
172
+ });
173
+ const data = await res.json();
174
+ if (data.success) {
175
+ li.remove();
176
+ } else {
177
+ el.disabled = false;
178
+ }
179
+ } catch {
180
+ el.disabled = false;
181
+ }
182
+ },
183
+
184
+ async setFilterMode(mode) {
185
+ try {
186
+ await fetch(this.mountPath + '/admin/reader/moderation/filter-mode', {
187
+ method: 'POST',
188
+ headers: {
189
+ 'Content-Type': 'application/json',
190
+ 'X-CSRF-Token': this.csrfToken,
191
+ },
192
+ body: JSON.stringify({ mode }),
193
+ });
194
+ } catch {
195
+ // Silently fail — radio will visually stay on selected
196
+ }
197
+ },
198
+ }));
199
+ });
200
+ </script>
112
201
  {% endblock %}
@@ -6,7 +6,24 @@
6
6
  {% 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) %}
7
7
  {% if hasCardContent or hasCardTitle or hasCardMedia %}
8
8
 
9
- <article class="ap-card{% if item.type %} ap-card--{{ item.type }}{% endif %}{% if item.inReplyTo %} ap-card--reply{% endif %}">
9
+ <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 %}">
10
+ {# Moderation content warning wrapper #}
11
+ {% if item._moderated %}
12
+ {% set modReason = item._moderationReason %}
13
+ {% if modReason == "muted_account" %}
14
+ {% set modLabel = __("activitypub.moderation.cwMutedAccount") %}
15
+ {% elif modReason.startsWith("muted_keyword:") %}
16
+ {% set modLabel = __("activitypub.moderation.cwMutedKeyword") + " \"" + modReason.replace("muted_keyword:", "") + "\"" %}
17
+ {% else %}
18
+ {% set modLabel = __("activitypub.moderation.cwFiltered") %}
19
+ {% endif %}
20
+ <div class="ap-card__moderation-cw" x-data="{ shown: false }">
21
+ <button @click="shown = !shown" class="ap-card__moderation-toggle">
22
+ <span x-show="!shown">🛡️ {{ modLabel }} — {{ __("activitypub.reader.showContent") }}</span>
23
+ <span x-show="shown" x-cloak>🛡️ {{ modLabel }} — {{ __("activitypub.reader.hideContent") }}</span>
24
+ </button>
25
+ <div x-show="shown" x-cloak>
26
+ {% endif %}
10
27
  {# Boost header if this is a boosted post #}
11
28
  {% if item.type == "boost" and item.boostedBy %}
12
29
  <div class="ap-card__boost">