Files
indiekit-endpoint-activitypub/views/activitypub-moderation.njk
Ricardo 4e514235c2 feat: ActivityPub reader — timeline, notifications, compose, moderation
Add a dedicated fediverse reader view with:
- Timeline view showing posts from followed accounts with threading,
  content warnings, boosts, and media display
- Compose form with dual-path posting (quick AP reply + Micropub blog post)
- Native AP interactions (like, boost, reply, follow/unfollow)
- Notifications view for likes, boosts, follows, mentions, replies
- Moderation tools (mute/block actors, keyword filters)
- Remote actor profile pages with follow state
- Automatic timeline cleanup with configurable retention
- CSRF protection, XSS prevention, input validation throughout

Removes Microsub bridge dependency — AP content now lives in its own
MongoDB collections (ap_timeline, ap_notifications, ap_interactions,
ap_muted, ap_blocked).

Bumps version to 1.1.0.
2026-02-21 12:13:10 +01:00

119 lines
4.9 KiB
Plaintext

{% extends "layouts/reader.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "prose/macro.njk" import prose with context %}
{% block content %}
{{ heading({
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{# 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 }">
<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>
</li>
{% endfor %}
</ul>
{% else %}
{{ prose({ text: __("activitypub.moderation.noBlocked") }) }}
{% endif %}
</section>
{# Muted actors #}
<section class="ap-moderation__section">
<h2>{{ __("activitypub.moderation.mutedActorsTitle") }}</h2>
{% set mutedActors = muted | selectattr("url") %}
{% if mutedActors | length > 0 %}
<ul class="ap-moderation__list">
{% for entry in mutedActors %}
<li class="ap-moderation__entry"
x-data="{ removing: false }">
<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>
</li>
{% endfor %}
</ul>
{% else %}
{{ prose({ text: __("activitypub.moderation.noMutedActors") }) }}
{% endif %}
</section>
{# Muted keywords #}
<section class="ap-moderation__section">
<h2>{{ __("activitypub.moderation.mutedKeywordsTitle") }}</h2>
{% 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>
<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>
</li>
{% endfor %}
</ul>
{% else %}
{{ prose({ text: __("activitypub.moderation.noMutedKeywords") }) }}
{% 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"
placeholder="{{ __('activitypub.moderation.keywordPlaceholder') }}"
class="ap-moderation__input">
<button type="submit" :disabled="submitting" class="ap-moderation__add-btn">
{{ __("activitypub.moderation.addKeyword") }}
</button>
</form>
</section>
{% endblock %}