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);
}
.ap-notification__icon {
.ap-notification__avatar-wrap {
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 {
+5 -4
View File
@@ -66,9 +66,10 @@ export async function getNotifications(collections, options = {}) {
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) {
query.published = { $lt: new Date(options.before) };
query.published = { $lt: options.before };
}
const rawItems = await ap_notifications
@@ -85,9 +86,9 @@ export async function getNotifications(collections, options = {}) {
: item.published,
}));
// Generate cursor for next page
// Generate cursor for next page (only if full page returned = more may exist)
const before =
items.length > 0
items.length === limit
? items[items.length - 1].published
: null;
+16 -14
View File
@@ -94,23 +94,20 @@ export async function getTimelineItems(collections, options = {}) {
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) {
const beforeDate = new Date(options.before);
if (Number.isNaN(beforeDate.getTime())) {
if (Number.isNaN(new Date(options.before).getTime())) {
throw new Error("Invalid before cursor");
}
query.published = { $lt: beforeDate };
query.published = { $lt: options.before };
} else if (options.after) {
const afterDate = new Date(options.after);
if (Number.isNaN(afterDate.getTime())) {
if (Number.isNaN(new Date(options.after).getTime())) {
throw new Error("Invalid after cursor");
}
query.published = { $gt: afterDate };
query.published = { $gt: options.after };
}
const rawItems = await ap_timeline
@@ -128,13 +125,16 @@ export async function getTimelineItems(collections, options = {}) {
}));
// 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 =
items.length > 0
? items[0].published
items.length === limit
? items[items.length - 1].published
: null;
const after =
items.length > 0
? items[items.length - 1].published
items.length > 0 && (options.before || options.after)
? items[0].published
: null;
return {
@@ -190,7 +190,9 @@ export async function updateTimelineItem(collections, uid, updates) {
*/
export async function deleteOldTimelineItems(collections, cutoffDate) {
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;
}
+1 -1
View File
@@ -7,7 +7,7 @@
{% from "pagination/macro.njk" import pagination with context %}
{% block content %}
{{ heading({ text: __("activitypub.activities"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
{{ heading({ text: __("activitypub.activities"), level: 1 }) }}
{% if activities.length > 0 %}
{% for activity in activities %}
+1 -5
View File
@@ -3,11 +3,7 @@
{% from "heading/macro.njk" import heading with context %}
{% block readercontent %}
{{ heading({
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{{ heading({ text: title, level: 1 }) }}
{# Reply context — show the post being replied to #}
{% if replyContext %}
+1 -1
View File
@@ -4,7 +4,7 @@
{% from "prose/macro.njk" import prose with context %}
{% block content %}
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
{{ heading({ text: title, level: 1 }) }}
{% if tags.length > 0 %}
<table class="table">
+1 -1
View File
@@ -4,7 +4,7 @@
{% from "prose/macro.njk" import prose with context %}
{% block content %}
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
{{ heading({ text: title, level: 1 }) }}
{% if pinned.length > 0 %}
<table class="table">
+1 -1
View File
@@ -6,7 +6,7 @@
{% from "pagination/macro.njk" import pagination with context %}
{% 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 %}
{% for follower in followers %}
+1 -1
View File
@@ -7,7 +7,7 @@
{% from "pagination/macro.njk" import pagination with context %}
{% 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 %}
{% for account in following %}
+1 -1
View File
@@ -8,7 +8,7 @@
{% from "prose/macro.njk" import prose with context %}
{% block content %}
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
{{ heading({ text: title, level: 1 }) }}
{% if result %}
{{ notificationBanner({ type: result.type, text: result.text }) }}
+1 -5
View File
@@ -4,11 +4,7 @@
{% from "prose/macro.njk" import prose with context %}
{% block readercontent %}
{{ heading({
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{{ heading({ text: title, level: 1 }) }}
{# Blocked actors #}
<section class="ap-moderation__section">
+1 -5
View File
@@ -4,11 +4,7 @@
{% from "prose/macro.njk" import prose with context %}
{% block readercontent %}
{{ heading({
text: __("activitypub.notifications.title"),
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{{ heading({ text: __("activitypub.notifications.title"), level: 1 }) }}
{% if items.length > 0 %}
<div class="ap-notifications__toolbar">
+1 -5
View File
@@ -3,11 +3,7 @@
{% from "heading/macro.njk" import heading with context %}
{% block readercontent %}
{{ heading({
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{{ heading({ text: title, level: 1 }) }}
<div class="ap-post-detail" data-mount-path="{{ mountPath }}">
{# Back button #}
+1 -1
View File
@@ -10,7 +10,7 @@
{% from "prose/macro.njk" import prose with context %}
{% block content %}
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
{{ heading({ text: title, level: 1 }) }}
{% if result %}
{{ notificationBanner({ type: result.type, text: result.text }) }}
+4 -8
View File
@@ -4,17 +4,10 @@
{% from "prose/macro.njk" import prose with context %}
{% block readercontent %}
{{ heading({
text: __("activitypub.reader.title"),
level: 1,
parent: { text: __("activitypub.title"), href: mountPath }
}) }}
{{ heading({ text: __("activitypub.reader.title"), level: 1 }) }}
{# Tab navigation #}
<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">
{{ __("activitypub.reader.tabs.notes") }}
</a>
@@ -30,6 +23,9 @@
<a href="?tab=media" 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">
{{ __("activitypub.reader.tabs.all") }}
</a>
</nav>
{# Timeline items #}
+1 -5
View File
@@ -4,11 +4,7 @@
{% from "prose/macro.njk" import prose with context %}
{% block readercontent %}
{{ heading({
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{{ heading({ text: title, level: 1 }) }}
<div class="ap-profile"
x-data="{
+3 -2
View File
@@ -24,8 +24,9 @@
{# Author header #}
<header class="ap-card__author">
{% if item.author.photo %}
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar"
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 "?" }}'}))">
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" loading="lazy" crossorigin="anonymous"
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 %}
<span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
{% endif %}
+11 -12
View File
@@ -8,19 +8,18 @@
<button type="submit" class="ap-notification__dismiss-btn" title="{{ __('activitypub.notifications.dismiss') }}">&times;</button>
</form>
{# Type icon #}
<div class="ap-notification__icon">
{% if item.type == "like" %}
{% elif item.type == "boost" %}
🔁
{% elif item.type == "follow" %}
👤
{% elif item.type == "reply" %}
💬
{% elif item.type == "mention" %}
@
{# Actor avatar with type badge #}
<div class="ap-notification__avatar-wrap">
{% if item.actorPhoto %}
<img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous"
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>
{% else %}
<span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
{% 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>
{# Notification body #}