feat: wire reply intelligence to frontend — timeline filtering, thread reconstruction, visibility badges

- Filter isContext items and private/direct posts from main timeline, new post count, and unread count
- Post detail: query local replies from ap_timeline before remote fetch, deduplicate, sort chronologically
- Add visibility badge (unlisted/private/direct) on item cards next to timestamp

Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
This commit is contained in:
Ricardo
2026-03-17 13:13:51 +01:00
parent a87fe59259
commit c8aa0383b9
4 changed files with 100 additions and 35 deletions

View File

@@ -351,6 +351,12 @@
margin-left: 0.2em;
}
.ap-card__visibility {
font-size: var(--font-size-xs);
margin-left: 0.3em;
opacity: 0.7;
}
.ap-card__timestamp-link {
color: inherit;
text-decoration: none;

View File

@@ -62,51 +62,87 @@ async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxD
return parents;
}
// Load replies collection (best-effort)
async function loadReplies(object, ctx, documentLoader, timelineCol, maxReplies = 10) {
const replies = [];
// Load local replies from ap_timeline (items where inReplyTo matches this post)
async function loadLocalReplies(timelineCol, postUrl, postUid, maxReplies = 20) {
if (!timelineCol) return [];
try {
const repliesCollection = await object.getReplies({ documentLoader });
if (!repliesCollection) return replies;
const matchUrls = [postUrl, postUid].filter(Boolean);
if (matchUrls.length === 0) return [];
let items = [];
const localReplies = await timelineCol
.find({ inReplyTo: { $in: matchUrls } })
.sort({ published: 1 })
.limit(maxReplies)
.toArray();
return localReplies;
}
// Load replies collection (best-effort) — merges local + remote
async function loadReplies(object, ctx, documentLoader, timelineCol, maxReplies = 20) {
const postUrl = object?.id?.href || object?.url?.href;
// Start with local replies already in our timeline (from organic inbox delivery
// or reply chain fetching). These are fast and free — no network requests.
const seenUrls = new Set();
const replies = await loadLocalReplies(timelineCol, postUrl, postUrl, maxReplies);
for (const r of replies) {
if (r.uid) seenUrls.add(r.uid);
if (r.url) seenUrls.add(r.url);
}
// Supplement with remote replies collection (may contain items we don't have locally)
if (object && replies.length < maxReplies) {
try {
items = await repliesCollection.getItems({ documentLoader });
} catch {
return replies;
}
const repliesCollection = await object.getReplies({ documentLoader });
if (repliesCollection) {
let items = [];
try {
items = await repliesCollection.getItems({ documentLoader });
} catch {
// Remote fetch failed — continue with local replies only
}
for (const replyItem of items.slice(0, maxReplies)) {
try {
const replyUrl = replyItem.id?.href || replyItem.url?.href;
if (!replyUrl) continue;
for (const replyItem of items.slice(0, maxReplies - replies.length)) {
try {
const replyUrl = replyItem.id?.href || replyItem.url?.href;
if (!replyUrl || seenUrls.has(replyUrl)) continue;
seenUrls.add(replyUrl);
// Check timeline first
let reply = timelineCol
? await timelineCol.findOne({
$or: [{ uid: replyUrl }, { url: replyUrl }],
})
: null;
// Check timeline first
let reply = timelineCol
? await timelineCol.findOne({
$or: [{ uid: replyUrl }, { url: replyUrl }],
})
: null;
if (!reply) {
// Extract from the item we already have
if (replyItem instanceof Note || replyItem instanceof Article) {
reply = await extractObjectData(replyItem);
if (!reply) {
// Extract from the item we already have
if (replyItem instanceof Note || replyItem instanceof Article) {
reply = await extractObjectData(replyItem);
}
}
if (reply) {
replies.push(reply);
}
} catch {
continue; // Skip failed replies
}
}
if (reply) {
replies.push(reply);
}
} catch {
continue; // Skip failed replies
}
} catch {
// getReplies() failed or not available
}
} catch {
// getReplies() failed or not available
}
// Sort all replies chronologically
replies.sort((a, b) => {
const dateA = a.published || "";
const dateB = b.published || "";
return dateA < dateB ? -1 : dateA > dateB ? 1 : 0;
});
return replies;
}

View File

@@ -73,6 +73,18 @@ export async function getTimelineItems(collections, options = {}) {
const query = {};
// Exclude context-only items (ancestors fetched for thread reconstruction)
// unless explicitly requested via options.includeContext
if (!options.includeContext) {
query.isContext = { $ne: true };
}
// Exclude private/direct posts from the main timeline feed —
// these belong in messages/notifications, not the public reader
if (!options.includePrivate) {
query.visibility = { $nin: ["private", "direct"] };
}
// Type filter
if (options.type) {
query.type = options.type;
@@ -252,7 +264,11 @@ export async function countNewItems(collections, after, options = {}) {
const { ap_timeline } = collections;
if (!after || Number.isNaN(new Date(after).getTime())) return 0;
const query = { published: { $gt: after } };
const query = {
published: { $gt: after },
isContext: { $ne: true },
visibility: { $nin: ["private", "direct"] },
};
if (options.type) query.type = options.type;
if (options.excludeReplies) {
query.$or = [
@@ -289,5 +305,9 @@ export async function markItemsRead(collections, uids) {
*/
export async function countUnreadItems(collections) {
const { ap_timeline } = collections;
return await ap_timeline.countDocuments({ read: { $ne: true } });
return await ap_timeline.countDocuments({
read: { $ne: true },
isContext: { $ne: true },
visibility: { $nin: ["private", "direct"] },
});
}

View File

@@ -65,6 +65,9 @@
</time>
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %}
</a>
{% if item.visibility and item.visibility != "public" %}
<span class="ap-card__visibility ap-card__visibility--{{ item.visibility }}" title="{% if item.visibility == 'unlisted' %}{{ __('activitypub.reader.compose.visibilityUnlisted') }}{% elif item.visibility == 'private' %}{{ __('activitypub.reader.compose.visibilityFollowers') }}{% elif item.visibility == 'direct' %}DM{% endif %}">{% if item.visibility == "unlisted" %}🔓{% elif item.visibility == "private" %}🔒{% elif item.visibility == "direct" %}✉️{% endif %}</span>
{% endif %}
{% endif %}
</header>