diff --git a/assets/reader.css b/assets/reader.css
index 1988295..40c414f 100644
--- a/assets/reader.css
+++ b/assets/reader.css
@@ -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;
diff --git a/lib/controllers/post-detail.js b/lib/controllers/post-detail.js
index 90776d0..a79304a 100644
--- a/lib/controllers/post-detail.js
+++ b/lib/controllers/post-detail.js
@@ -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;
}
diff --git a/lib/storage/timeline.js b/lib/storage/timeline.js
index 515f6c9..109c5d4 100644
--- a/lib/storage/timeline.js
+++ b/lib/storage/timeline.js
@@ -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"] },
+ });
}
diff --git a/views/partials/ap-item-card.njk b/views/partials/ap-item-card.njk
index 6c77c21..7485502 100644
--- a/views/partials/ap-item-card.njk
+++ b/views/partials/ap-item-card.njk
@@ -65,6 +65,9 @@
{% if item.updated %}✏️{% endif %}
+ {% if item.visibility and item.visibility != "public" %}
+ {% if item.visibility == "unlisted" %}🔓{% elif item.visibility == "private" %}🔒{% elif item.visibility == "direct" %}✉️{% endif %}
+ {% endif %}
{% endif %}