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 %}