feat: new posts banner, mark-as-read on scroll, unread filter

- Poll every 30s for new items, show sticky "N new posts — Load" banner
- IntersectionObserver marks cards as read at 50% visibility, batches to
  server every 5s
- Read cards fade to 70% opacity, full opacity on hover
- "Unread" toggle in tab bar filters to unread-only items
- New API: GET /api/timeline/count-new, POST /api/timeline/mark-read

Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
This commit is contained in:
Ricardo
2026-03-02 10:54:11 +01:00
parent 68aadb6ff2
commit 508ac75363
8 changed files with 381 additions and 17 deletions

View File

@@ -64,32 +64,57 @@
{# Tab navigation #}
<nav class="ap-tabs" role="tablist">
<a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=notes{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.notes") }}
</a>
<a href="?tab=articles" class="ap-tab{% if tab == 'articles' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=articles{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'articles' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.articles") }}
</a>
<a href="?tab=replies" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=replies{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.replies") }}
</a>
<a href="?tab=boosts" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=boosts{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.boosts") }}
</a>
<a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=media{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.media") }}
</a>
<a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
<a href="?tab=all{% if unread %}&unread=1{% endif %}" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.all") }}
</a>
<a href="?tab={{ tab }}{% if not unread %}&unread=1{% endif %}" class="ap-tab ap-unread-toggle{% if unread %} ap-unread-toggle--active{% endif %}" title="{% if unread %}Show all posts{% else %}Show unread only{% endif %}">
{% if unread %}
All posts
{% else %}
Unread{% if unreadTimelineCount %} ({{ unreadTimelineCount }}){% endif %}
{% endif %}
</a>
</nav>
{# Timeline items #}
{# New posts banner — polls every 30s, shows count of new items #}
{% if items.length > 0 %}
<div class="ap-new-posts-banner"
x-data="apNewPostsBanner()"
data-newest="{{ items[0].published }}"
data-tab="{{ tab }}"
data-mount-path="{{ mountPath }}"
x-show="count > 0"
x-cloak>
<button class="ap-new-posts-banner__btn" @click="loadNew()">
<span x-text="count + ' new post' + (count !== 1 ? 's' : '')"></span> — Load
</button>
</div>
{% endif %}
{# Timeline items with read tracking #}
{% if items.length > 0 %}
<div class="ap-timeline"
id="ap-timeline"
data-mount-path="{{ mountPath }}"
data-before="{{ before if before else '' }}">
data-before="{{ before if before else '' }}"
data-csrf-token="{{ csrfToken }}"
x-data="apReadTracker()"
x-init="init()">
{% for item in items %}
{% include "partials/ap-item-card.njk" %}
{% endfor %}

View File

@@ -6,7 +6,7 @@
{% 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) %}
{% if hasCardContent or hasCardTitle or hasCardMedia %}
<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 %}">
<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 %}{% if item.read %} ap-card--read{% endif %}" data-uid="{{ item.uid }}">
{# Moderation content warning wrapper #}
{% if item._moderated %}
{% if item._moderationReason == "muted_account" %}