@rmdes/indiekit-endpoint-activitypub 2.0.11 → 2.0.13
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 +40 -0
- package/index.js +3 -1
- package/lib/controllers/moderation.js +48 -2
- package/lib/controllers/my-profile.js +15 -26
- package/lib/controllers/reader.js +45 -17
- package/lib/storage/moderation.js +27 -0
- package/locales/en.json +8 -1
- package/package.json +1 -1
- package/views/activitypub-moderation.njk +146 -57
- package/views/partials/ap-item-card.njk +18 -1
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));
|
|
@@ -480,7 +482,7 @@ export default class ActivityPubEndpoint {
|
|
|
480
482
|
type: typeName,
|
|
481
483
|
actorUrl: self._publicationUrl,
|
|
482
484
|
objectUrl: properties.url,
|
|
483
|
-
targetUrl:
|
|
485
|
+
targetUrl: properties["in-reply-to"] || undefined,
|
|
484
486
|
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}`,
|
|
485
487
|
});
|
|
486
488
|
|
|
@@ -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
|
|
291
|
-
|
|
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
|
+
}
|
|
@@ -137,41 +137,30 @@ export function myProfileController(plugin) {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
case "replies": {
|
|
140
|
-
|
|
141
|
-
if (
|
|
140
|
+
// Query posts collection for reply-type posts (have in-reply-to)
|
|
141
|
+
if (postsCollection) {
|
|
142
142
|
const query = {
|
|
143
|
-
|
|
144
|
-
type: "Create",
|
|
145
|
-
targetUrl: { $exists: true, $ne: null },
|
|
143
|
+
"properties.post-type": "reply",
|
|
146
144
|
};
|
|
147
145
|
if (before) {
|
|
148
|
-
query.
|
|
146
|
+
query["properties.published"] = { $lt: before };
|
|
149
147
|
}
|
|
150
148
|
|
|
151
|
-
const
|
|
149
|
+
const replies = await postsCollection
|
|
152
150
|
.find(query)
|
|
153
|
-
.sort({
|
|
151
|
+
.sort({ "properties.published": -1 })
|
|
154
152
|
.limit(PAGE_LIMIT)
|
|
155
153
|
.toArray();
|
|
156
154
|
|
|
157
|
-
items =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
author: {
|
|
167
|
-
name: profile?.name || a.actorName || "",
|
|
168
|
-
url: profile?.url || a.actorUrl || "",
|
|
169
|
-
photo: profile?.icon || "",
|
|
170
|
-
},
|
|
171
|
-
}));
|
|
172
|
-
|
|
173
|
-
if (activities.length === PAGE_LIMIT) {
|
|
174
|
-
nextBefore = activities[activities.length - 1].receivedAt;
|
|
155
|
+
items = replies.map((p) => {
|
|
156
|
+
const card = postToCardItem(p, profile);
|
|
157
|
+
card.inReplyTo = p.properties?.["in-reply-to"] || null;
|
|
158
|
+
card.type = "reply";
|
|
159
|
+
return card;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (replies.length === PAGE_LIMIT) {
|
|
163
|
+
nextBefore = items[items.length - 1].published;
|
|
175
164
|
}
|
|
176
165
|
}
|
|
177
166
|
break;
|
|
@@ -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] =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
//
|
|
92
|
-
if (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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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;
|
|
@@ -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.
|
|
3
|
+
"version": "2.0.13",
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
{
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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-
|
|
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">
|