feat: enhance ActivityPub reader with mentions, hashtags, infinite scroll, explore, and tag following

- Fix mentions/hashtags bug: separate Fedify Mention and Hashtag types into
  distinct mentions[] and category[] arrays with proper @ and # rendering
- Add hashtag timeline filtering at /admin/reader/tag with regex-safe queries
- Replace prev/next pagination with AlpineJS infinite scroll (IntersectionObserver)
  with no-JS fallback pagination preserved
- Add public instance timeline explorer at /admin/reader/explore with SSRF
  prevention and XSS sanitization via Mastodon-compatible API
- Add hashtag following with ap_followed_tags collection, inbox listener
  integration for non-followed accounts, and followed tags sidebar display
- Include one-time migration script for legacy timeline data
This commit is contained in:
Ricardo
2026-02-26 18:15:21 +01:00
parent 2c4ffeaba0
commit a4f72a588d
19 changed files with 1656 additions and 20 deletions

View File

@@ -0,0 +1,82 @@
{% extends "layouts/ap-reader.njk" %}
{% from "prose/macro.njk" import prose with context %}
{% block readercontent %}
{# Page header #}
<header class="ap-explore-header">
<h2 class="ap-explore-header__title">{{ __("activitypub.reader.explore.title") }}</h2>
<p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
</header>
{# Instance form #}
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form">
<div class="ap-explore-form__row">
<input
type="text"
name="instance"
value="{{ instance }}"
class="ap-explore-form__input"
placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
autocomplete="off"
required>
<div class="ap-explore-form__scope">
<label class="ap-explore-form__scope-label">
<input type="radio" name="scope" value="local"
{% if scope == "local" %}checked{% endif %}>
{{ __("activitypub.reader.explore.local") }}
</label>
<label class="ap-explore-form__scope-label">
<input type="radio" name="scope" value="federated"
{% if scope == "federated" %}checked{% endif %}>
{{ __("activitypub.reader.explore.federated") }}
</label>
</div>
<button type="submit" class="ap-explore-form__btn">
{{ __("activitypub.reader.explore.browse") }}
</button>
</div>
</form>
{# Error state #}
{% if error %}
<div class="ap-explore-error">{{ error }}</div>
{% endif %}
{# Results #}
{% if instance and not error %}
{% if items.length > 0 %}
<div class="ap-timeline ap-explore-timeline"
id="ap-explore-timeline"
data-instance="{{ instance }}"
data-scope="{{ scope }}"
data-mount-path="{{ mountPath }}"
data-max-id="{{ maxId if maxId else '' }}">
{% for item in items %}
{% include "partials/ap-item-card.njk" %}
{% endfor %}
</div>
{# Infinite scroll for explore page #}
{% if maxId %}
<div class="ap-load-more"
id="ap-explore-load-more"
data-max-id="{{ maxId }}"
data-instance="{{ instance }}"
data-scope="{{ scope }}"
x-data="apExploreScroll()"
x-init="init()">
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
<span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
<span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
</button>
<p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
</div>
{% endif %}
{% elif instance %}
{{ prose({ text: __("activitypub.reader.explore.noResults") }) }}
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -4,6 +4,23 @@
{% from "prose/macro.njk" import prose with context %}
{% block readercontent %}
{# Explore link #}
<div class="ap-reader-tools">
<a href="{{ mountPath }}/admin/reader/explore" class="ap-reader-tools__explore">
🔭 {{ __("activitypub.reader.explore.title") }}
</a>
</div>
{# Followed tags #}
{% if followedTags and followedTags.length > 0 %}
<div class="ap-followed-tags">
<span class="ap-followed-tags__label">{{ __("activitypub.reader.tagTimeline.following") }}:</span>
{% for tag in followedTags %}
<a href="{{ mountPath }}/admin/reader/tag?tag={{ tag | urlencode }}" class="ap-card__tag">#{{ tag }}</a>
{% endfor %}
</div>
{% endif %}
{# Fediverse lookup #}
<form action="{{ mountPath }}/admin/reader/resolve" method="get" class="ap-lookup">
<input type="text" name="q" class="ap-lookup__input"
@@ -36,15 +53,18 @@
{# Timeline items #}
{% if items.length > 0 %}
<div class="ap-timeline" data-mount-path="{{ mountPath }}">
<div class="ap-timeline"
id="ap-timeline"
data-mount-path="{{ mountPath }}"
data-before="{{ before if before else '' }}">
{% for item in items %}
{% include "partials/ap-item-card.njk" %}
{% endfor %}
</div>
{# Pagination #}
{# Pagination (progressive enhancement — visible without JS, hidden when Alpine active) #}
{% if before or after %}
<nav class="ap-pagination">
<nav class="ap-pagination ap-pagination--js-hidden" id="ap-reader-pagination">
{% if after %}
<a href="?tab={{ tab }}&after={{ after }}" class="ap-pagination__prev">
{{ __("activitypub.reader.pagination.newer") }}
@@ -57,6 +77,25 @@
{% endif %}
</nav>
{% endif %}
{# Infinite scroll load-more sentinel #}
{% if before %}
<div class="ap-load-more"
id="ap-load-more"
data-before="{{ before }}"
data-tab="{{ tab }}"
data-tag=""
x-data="apInfiniteScroll()"
x-init="init()"
@ap-append-items.window="appendItems($event.detail)">
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
<span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
<span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
</button>
<p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
</div>
{% endif %}
{% else %}
{{ prose({ text: __("activitypub.reader.empty") }) }}
{% endif %}

View File

@@ -0,0 +1,86 @@
{% extends "layouts/ap-reader.njk" %}
{% from "prose/macro.njk" import prose with context %}
{% block readercontent %}
{# Tag header #}
<header class="ap-tag-header">
<div class="ap-tag-header__info">
<h2 class="ap-tag-header__title">#{{ tag }}</h2>
<p class="ap-tag-header__count">
{{ __("activitypub.reader.tagTimeline.postsTagged", items.length) }}
</p>
</div>
<div class="ap-tag-header__actions">
{% if isFollowed %}
<form action="{{ mountPath }}/admin/reader/unfollow-tag" method="post" class="ap-tag-header__follow-form">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input type="hidden" name="tag" value="{{ tag }}">
<button type="submit" class="ap-tag-header__unfollow-btn">
{{ __("activitypub.reader.tagTimeline.unfollowTag") }}
</button>
</form>
{% else %}
<form action="{{ mountPath }}/admin/reader/follow-tag" method="post" class="ap-tag-header__follow-form">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input type="hidden" name="tag" value="{{ tag }}">
<button type="submit" class="ap-tag-header__follow-btn">
{{ __("activitypub.reader.tagTimeline.followTag") }}
</button>
</form>
{% endif %}
<a href="{{ mountPath }}/admin/reader" class="ap-tag-header__back">
← {{ __("activitypub.reader.title") }}
</a>
</div>
</header>
{# Timeline items #}
{% if items.length > 0 %}
<div class="ap-timeline"
data-mount-path="{{ mountPath }}"
data-tag="{{ tag }}"
data-before="{{ before if before else '' }}">
{% for item in items %}
{% include "partials/ap-item-card.njk" %}
{% endfor %}
</div>
{# Pagination (progressive enhancement fallback — hidden when infinite scroll JS active) #}
{% if before or after %}
<nav class="ap-pagination ap-pagination--js-hidden" id="ap-tag-pagination">
{% if after %}
<a href="?tag={{ tag }}&after={{ after }}" class="ap-pagination__prev">
{{ __("activitypub.reader.pagination.newer") }}
</a>
{% endif %}
{% if before %}
<a href="?tag={{ tag }}&before={{ before }}" class="ap-pagination__next">
{{ __("activitypub.reader.pagination.older") }}
</a>
{% endif %}
</nav>
{% endif %}
{# Infinite scroll sentinel (Task 5) #}
{% if before %}
<div class="ap-load-more"
id="ap-load-more"
data-before="{{ before }}"
data-tab=""
data-tag="{{ tag }}"
x-data="apInfiniteScroll()"
x-init="init()"
@ap-append-items.window="appendItems($event.detail)">
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
<span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
<span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
</button>
<p class="ap-load-more__done" x-show="done">{{ __("activitypub.reader.pagination.noMore") }}</p>
</div>
{% endif %}
{% else %}
{{ prose({ text: __("activitypub.reader.tagTimeline.noPosts", tag) }) }}
{% endif %}
{% endblock %}

View File

@@ -1,7 +1,10 @@
{% extends "document.njk" %}
{% block content %}
{# Alpine.js for client-side reactivity (CW toggles, interaction buttons) #}
{# Infinite scroll component — must load before Alpine to register via alpine:init #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
{# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
{# Reader stylesheet — loaded in body is fine for modern browsers #}

View File

@@ -113,12 +113,27 @@
{% include "partials/ap-item-media.njk" %}
{% endif %}
{# Tags/categories #}
{% if item.category and item.category.length > 0 %}
{# Mentions and hashtags #}
{% set hasMentions = item.mentions and item.mentions.length > 0 %}
{% set hasHashtags = item.category and item.category.length > 0 %}
{% if hasMentions or hasHashtags %}
<div class="ap-card__tags">
{% for tag in item.category %}
<a href="?tag={{ tag }}" class="ap-card__tag">#{{ tag }}</a>
{% endfor %}
{# Mentions — render with @ prefix, link to profile view when URL available #}
{% if hasMentions %}
{% for mention in item.mentions %}
{% if mention.url %}
<a href="{{ mountPath }}/admin/reader/profile?url={{ mention.url | urlencode }}" class="ap-card__mention">@{{ mention.name }}</a>
{% else %}
<span class="ap-card__mention ap-card__mention--legacy">@{{ mention.name }}</span>
{% endif %}
{% endfor %}
{% endif %}
{# Hashtags — render with # prefix, link to tag timeline #}
{% if hasHashtags %}
{% for tag in item.category %}
<a href="{{ mountPath }}/admin/reader/tag?tag={{ tag | urlencode }}" class="ap-card__tag">#{{ tag }}</a>
{% endfor %}
{% endif %}
</div>
{% endif %}