fix: pagination, headers, avatars, tab order, and notification UI

- Fix cursor pagination: use string comparison (not Date objects) for
  published field queries in both timeline and notifications
- Fix "Older" cursor to use oldest item's date, not newest
- Remove redundant parent breadcrumb from all AP page headings
- Reorder tabs: Notes first, All last
- Fix avatar loading: non-destructive hide/show with lazy loading
- Add actor avatars with type badge overlay to notification cards
- Add Fediverse navigation group in sidebar
This commit is contained in:
Ricardo
2026-02-21 20:28:40 +01:00
parent 937c0a8226
commit 31418310d2
18 changed files with 78 additions and 74 deletions
+27 -2
View File
@@ -793,9 +793,34 @@
box-shadow: 0 0 8px 0 hsl(var(--tint-yellow) 50% / 0.3); box-shadow: 0 0 8px 0 hsl(var(--tint-yellow) 50% / 0.3);
} }
.ap-notification__icon { .ap-notification__avatar-wrap {
flex-shrink: 0; flex-shrink: 0;
font-size: 1.5em; position: relative;
}
.ap-notification__avatar {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: 50%;
height: 40px;
object-fit: cover;
width: 40px;
}
.ap-notification__avatar--default {
align-items: center;
background: var(--color-offset-variant);
color: var(--color-on-offset);
display: inline-flex;
font-size: 1.1em;
font-weight: 600;
justify-content: center;
}
.ap-notification__type-badge {
bottom: -2px;
font-size: 0.75em;
position: absolute;
right: -4px;
} }
.ap-notification__body { .ap-notification__body {
+5 -4
View File
@@ -66,9 +66,10 @@ export async function getNotifications(collections, options = {}) {
query.read = false; query.read = false;
} }
// Cursor pagination // Cursor pagination — published is stored as ISO string, so compare
// as strings (lexicographic ISO 8601 comparison is correct for dates)
if (options.before) { if (options.before) {
query.published = { $lt: new Date(options.before) }; query.published = { $lt: options.before };
} }
const rawItems = await ap_notifications const rawItems = await ap_notifications
@@ -85,9 +86,9 @@ export async function getNotifications(collections, options = {}) {
: item.published, : item.published,
})); }));
// Generate cursor for next page // Generate cursor for next page (only if full page returned = more may exist)
const before = const before =
items.length > 0 items.length === limit
? items[items.length - 1].published ? items[items.length - 1].published
: null; : null;
+16 -14
View File
@@ -94,23 +94,20 @@ export async function getTimelineItems(collections, options = {}) {
query["author.url"] = options.authorUrl; query["author.url"] = options.authorUrl;
} }
// Cursor pagination — validate dates // Cursor pagination — published is stored as ISO string, so compare
// as strings (lexicographic ISO 8601 comparison is correct for dates)
if (options.before) { if (options.before) {
const beforeDate = new Date(options.before); if (Number.isNaN(new Date(options.before).getTime())) {
if (Number.isNaN(beforeDate.getTime())) {
throw new Error("Invalid before cursor"); throw new Error("Invalid before cursor");
} }
query.published = { $lt: beforeDate }; query.published = { $lt: options.before };
} else if (options.after) { } else if (options.after) {
const afterDate = new Date(options.after); if (Number.isNaN(new Date(options.after).getTime())) {
if (Number.isNaN(afterDate.getTime())) {
throw new Error("Invalid after cursor"); throw new Error("Invalid after cursor");
} }
query.published = { $gt: afterDate }; query.published = { $gt: options.after };
} }
const rawItems = await ap_timeline const rawItems = await ap_timeline
@@ -128,13 +125,16 @@ export async function getTimelineItems(collections, options = {}) {
})); }));
// Generate cursors for pagination // Generate cursors for pagination
// Items are sorted newest-first, so:
// - "before" cursor (for "Older" link) = oldest item's date (last in array)
// - "after" cursor (for "Newer" link) = newest item's date (first in array)
const before = const before =
items.length > 0 items.length === limit
? items[0].published ? items[items.length - 1].published
: null; : null;
const after = const after =
items.length > 0 items.length > 0 && (options.before || options.after)
? items[items.length - 1].published ? items[0].published
: null; : null;
return { return {
@@ -190,7 +190,9 @@ export async function updateTimelineItem(collections, uid, updates) {
*/ */
export async function deleteOldTimelineItems(collections, cutoffDate) { export async function deleteOldTimelineItems(collections, cutoffDate) {
const { ap_timeline } = collections; const { ap_timeline } = collections;
const result = await ap_timeline.deleteMany({ published: { $lt: cutoffDate } }); // published is stored as ISO string — convert cutoff to string for comparison
const cutoff = cutoffDate instanceof Date ? cutoffDate.toISOString() : cutoffDate;
const result = await ap_timeline.deleteMany({ published: { $lt: cutoff } });
return result.deletedCount; return result.deletedCount;
} }
+1 -1
View File
@@ -7,7 +7,7 @@
{% from "pagination/macro.njk" import pagination with context %} {% from "pagination/macro.njk" import pagination with context %}
{% block content %} {% block content %}
{{ heading({ text: __("activitypub.activities"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }} {{ heading({ text: __("activitypub.activities"), level: 1 }) }}
{% if activities.length > 0 %} {% if activities.length > 0 %}
{% for activity in activities %} {% for activity in activities %}
+1 -5
View File
@@ -3,11 +3,7 @@
{% from "heading/macro.njk" import heading with context %} {% from "heading/macro.njk" import heading with context %}
{% block readercontent %} {% block readercontent %}
{{ heading({ {{ heading({ text: title, level: 1 }) }}
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{# Reply context — show the post being replied to #} {# Reply context — show the post being replied to #}
{% if replyContext %} {% if replyContext %}
+1 -1
View File
@@ -4,7 +4,7 @@
{% from "prose/macro.njk" import prose with context %} {% from "prose/macro.njk" import prose with context %}
{% block content %} {% block content %}
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }} {{ heading({ text: title, level: 1 }) }}
{% if tags.length > 0 %} {% if tags.length > 0 %}
<table class="table"> <table class="table">
+1 -1
View File
@@ -4,7 +4,7 @@
{% from "prose/macro.njk" import prose with context %} {% from "prose/macro.njk" import prose with context %}
{% block content %} {% block content %}
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }} {{ heading({ text: title, level: 1 }) }}
{% if pinned.length > 0 %} {% if pinned.length > 0 %}
<table class="table"> <table class="table">
+1 -1
View File
@@ -6,7 +6,7 @@
{% from "pagination/macro.njk" import pagination with context %} {% from "pagination/macro.njk" import pagination with context %}
{% block content %} {% block content %}
{{ heading({ text: followerCount + " " + __("activitypub.followers"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }} {{ heading({ text: followerCount + " " + __("activitypub.followers"), level: 1 }) }}
{% if followers.length > 0 %} {% if followers.length > 0 %}
{% for follower in followers %} {% for follower in followers %}
+1 -1
View File
@@ -7,7 +7,7 @@
{% from "pagination/macro.njk" import pagination with context %} {% from "pagination/macro.njk" import pagination with context %}
{% block content %} {% block content %}
{{ heading({ text: followingCount + " " + __("activitypub.following"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }} {{ heading({ text: followingCount + " " + __("activitypub.following"), level: 1 }) }}
{% if following.length > 0 %} {% if following.length > 0 %}
{% for account in following %} {% for account in following %}
+1 -1
View File
@@ -8,7 +8,7 @@
{% from "prose/macro.njk" import prose with context %} {% from "prose/macro.njk" import prose with context %}
{% block content %} {% block content %}
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }} {{ heading({ text: title, level: 1 }) }}
{% if result %} {% if result %}
{{ notificationBanner({ type: result.type, text: result.text }) }} {{ notificationBanner({ type: result.type, text: result.text }) }}
+1 -5
View File
@@ -4,11 +4,7 @@
{% from "prose/macro.njk" import prose with context %} {% from "prose/macro.njk" import prose with context %}
{% block readercontent %} {% block readercontent %}
{{ heading({ {{ heading({ text: title, level: 1 }) }}
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{# Blocked actors #} {# Blocked actors #}
<section class="ap-moderation__section"> <section class="ap-moderation__section">
+1 -5
View File
@@ -4,11 +4,7 @@
{% from "prose/macro.njk" import prose with context %} {% from "prose/macro.njk" import prose with context %}
{% block readercontent %} {% block readercontent %}
{{ heading({ {{ heading({ text: __("activitypub.notifications.title"), level: 1 }) }}
text: __("activitypub.notifications.title"),
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{% if items.length > 0 %} {% if items.length > 0 %}
<div class="ap-notifications__toolbar"> <div class="ap-notifications__toolbar">
+1 -5
View File
@@ -3,11 +3,7 @@
{% from "heading/macro.njk" import heading with context %} {% from "heading/macro.njk" import heading with context %}
{% block readercontent %} {% block readercontent %}
{{ heading({ {{ heading({ text: title, level: 1 }) }}
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
<div class="ap-post-detail" data-mount-path="{{ mountPath }}"> <div class="ap-post-detail" data-mount-path="{{ mountPath }}">
{# Back button #} {# Back button #}
+1 -1
View File
@@ -10,7 +10,7 @@
{% from "prose/macro.njk" import prose with context %} {% from "prose/macro.njk" import prose with context %}
{% block content %} {% block content %}
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }} {{ heading({ text: title, level: 1 }) }}
{% if result %} {% if result %}
{{ notificationBanner({ type: result.type, text: result.text }) }} {{ notificationBanner({ type: result.type, text: result.text }) }}
+4 -8
View File
@@ -4,17 +4,10 @@
{% from "prose/macro.njk" import prose with context %} {% from "prose/macro.njk" import prose with context %}
{% block readercontent %} {% block readercontent %}
{{ heading({ {{ heading({ text: __("activitypub.reader.title"), level: 1 }) }}
text: __("activitypub.reader.title"),
level: 1,
parent: { text: __("activitypub.title"), href: mountPath }
}) }}
{# Tab navigation #} {# Tab navigation #}
<nav class="ap-tabs" role="tablist"> <nav class="ap-tabs" role="tablist">
<a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.all") }}
</a>
<a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab"> <a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.notes") }} {{ __("activitypub.reader.tabs.notes") }}
</a> </a>
@@ -30,6 +23,9 @@
<a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab"> <a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.media") }} {{ __("activitypub.reader.tabs.media") }}
</a> </a>
<a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.all") }}
</a>
</nav> </nav>
{# Timeline items #} {# Timeline items #}
+1 -5
View File
@@ -4,11 +4,7 @@
{% from "prose/macro.njk" import prose with context %} {% from "prose/macro.njk" import prose with context %}
{% block readercontent %} {% block readercontent %}
{{ heading({ {{ heading({ text: title, level: 1 }) }}
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
<div class="ap-profile" <div class="ap-profile"
x-data="{ x-data="{
+3 -2
View File
@@ -24,8 +24,9 @@
{# Author header #} {# Author header #}
<header class="ap-card__author"> <header class="ap-card__author">
{% if item.author.photo %} {% if item.author.photo %}
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" <img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" loading="lazy" crossorigin="anonymous"
onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'ap-card__avatar ap-card__avatar--default',textContent:'{{ item.author.name[0] | upper if item.author.name else "?" }}'}))"> onerror="this.style.display='none';this.nextElementSibling.style.display=''">
<span class="ap-card__avatar ap-card__avatar--default" style="display:none" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
{% else %} {% else %}
<span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span> <span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
{% endif %} {% endif %}
+11 -12
View File
@@ -8,19 +8,18 @@
<button type="submit" class="ap-notification__dismiss-btn" title="{{ __('activitypub.notifications.dismiss') }}">&times;</button> <button type="submit" class="ap-notification__dismiss-btn" title="{{ __('activitypub.notifications.dismiss') }}">&times;</button>
</form> </form>
{# Type icon #} {# Actor avatar with type badge #}
<div class="ap-notification__icon"> <div class="ap-notification__avatar-wrap">
{% if item.type == "like" %} {% if item.actorPhoto %}
<img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous"
{% elif item.type == "boost" %} onerror="this.style.display='none';this.nextElementSibling.style.display=''">
🔁 <span class="ap-notification__avatar ap-notification__avatar--default" style="display:none" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
{% elif item.type == "follow" %} {% else %}
👤 <span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
{% elif item.type == "reply" %}
💬
{% elif item.type == "mention" %}
@
{% endif %} {% endif %}
<span class="ap-notification__type-badge">
{% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% endif %}
</span>
</div> </div>
{# Notification body #} {# Notification body #}