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:
@@ -351,6 +351,12 @@
|
|||||||
margin-left: 0.2em;
|
margin-left: 0.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ap-card__visibility {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
margin-left: 0.3em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.ap-card__timestamp-link {
|
.ap-card__timestamp-link {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -62,51 +62,87 @@ async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxD
|
|||||||
return parents;
|
return parents;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load replies collection (best-effort)
|
// Load local replies from ap_timeline (items where inReplyTo matches this post)
|
||||||
async function loadReplies(object, ctx, documentLoader, timelineCol, maxReplies = 10) {
|
async function loadLocalReplies(timelineCol, postUrl, postUid, maxReplies = 20) {
|
||||||
const replies = [];
|
if (!timelineCol) return [];
|
||||||
|
|
||||||
try {
|
const matchUrls = [postUrl, postUid].filter(Boolean);
|
||||||
const repliesCollection = await object.getReplies({ documentLoader });
|
if (matchUrls.length === 0) return [];
|
||||||
if (!repliesCollection) return replies;
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
items = await repliesCollection.getItems({ documentLoader });
|
const repliesCollection = await object.getReplies({ documentLoader });
|
||||||
} catch {
|
if (repliesCollection) {
|
||||||
return replies;
|
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)) {
|
for (const replyItem of items.slice(0, maxReplies - replies.length)) {
|
||||||
try {
|
try {
|
||||||
const replyUrl = replyItem.id?.href || replyItem.url?.href;
|
const replyUrl = replyItem.id?.href || replyItem.url?.href;
|
||||||
if (!replyUrl) continue;
|
if (!replyUrl || seenUrls.has(replyUrl)) continue;
|
||||||
|
seenUrls.add(replyUrl);
|
||||||
|
|
||||||
// Check timeline first
|
// Check timeline first
|
||||||
let reply = timelineCol
|
let reply = timelineCol
|
||||||
? await timelineCol.findOne({
|
? await timelineCol.findOne({
|
||||||
$or: [{ uid: replyUrl }, { url: replyUrl }],
|
$or: [{ uid: replyUrl }, { url: replyUrl }],
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!reply) {
|
if (!reply) {
|
||||||
// Extract from the item we already have
|
// Extract from the item we already have
|
||||||
if (replyItem instanceof Note || replyItem instanceof Article) {
|
if (replyItem instanceof Note || replyItem instanceof Article) {
|
||||||
reply = await extractObjectData(replyItem);
|
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;
|
return replies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-2
@@ -73,6 +73,18 @@ export async function getTimelineItems(collections, options = {}) {
|
|||||||
|
|
||||||
const query = {};
|
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
|
// Type filter
|
||||||
if (options.type) {
|
if (options.type) {
|
||||||
query.type = options.type;
|
query.type = options.type;
|
||||||
@@ -252,7 +264,11 @@ export async function countNewItems(collections, after, options = {}) {
|
|||||||
const { ap_timeline } = collections;
|
const { ap_timeline } = collections;
|
||||||
if (!after || Number.isNaN(new Date(after).getTime())) return 0;
|
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.type) query.type = options.type;
|
||||||
if (options.excludeReplies) {
|
if (options.excludeReplies) {
|
||||||
query.$or = [
|
query.$or = [
|
||||||
@@ -289,5 +305,9 @@ export async function markItemsRead(collections, uids) {
|
|||||||
*/
|
*/
|
||||||
export async function countUnreadItems(collections) {
|
export async function countUnreadItems(collections) {
|
||||||
const { ap_timeline } = 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"] },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@
|
|||||||
</time>
|
</time>
|
||||||
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %}
|
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %}
|
||||||
</a>
|
</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 %}
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user