mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
82
views/activitypub-explore.njk
Normal file
82
views/activitypub-explore.njk
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
86
views/activitypub-tag-timeline.njk
Normal file
86
views/activitypub-tag-timeline.njk
Normal 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 %}
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user