mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user