b99f5fb73e
New upstream features:
- v2.13.0: FEP-8fcf/fe34 compliance, custom emoji, manual follow approval
- v2.14.0: Server blocking, Redis caching, key refresh, async inbox queue
- v2.15.0: Outbox failure handling (strike system), reply chain forwarding
- v2.15.1: Reply intelligence in reader (visibility badges, thread reconstruction)
- v2.15.2: Strip invalid as:Endpoints type from actor serialization
- v2.15.3: Exclude soft-deleted posts from outbox/content negotiation
- v2.15.4: Wire content-warning property for CW text
Conflict resolution:
- federation-setup.js: merged our draft/unlisted/visibility filters with
upstream's soft-delete filter
- compose.js: kept our DM compose path, adopted upstream's
lookupWithSecurity for remote object resolution
- notifications.js: kept our separate reply/mention tabs, added upstream's
follow_request grouping
- inbox-listeners.js: took upstream's thin-shim rewrite (handlers moved to
inbox-handlers.js which already has DM detection)
- notification-card.njk: merged DM badge with follow_request support
Preserved from our fork:
- Like/Announce to:Public cc:followers addressing
- Nested tag normalization (cat.split("/").at(-1))
- DM compose/reply path in compose controller
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
262 lines
8.1 KiB
JavaScript
262 lines
8.1 KiB
JavaScript
/**
|
|
* Notification storage operations
|
|
* @module storage/notifications
|
|
*/
|
|
|
|
/**
|
|
* Add a notification (uses atomic upsert for deduplication)
|
|
* @param {object} collections - MongoDB collections
|
|
* @param {object} notification - Notification data
|
|
* @param {string} notification.uid - Activity ID or constructed dedup key
|
|
* @param {string} notification.type - "like" | "boost" | "follow" | "mention" | "reply"
|
|
* @param {string} notification.actorUrl - Remote actor URL
|
|
* @param {string} notification.actorName - Display name
|
|
* @param {string} notification.actorPhoto - Avatar URL
|
|
* @param {string} notification.actorHandle - @user@instance
|
|
* @param {string} [notification.targetUrl] - The post they liked/boosted/replied to
|
|
* @param {string} [notification.targetName] - Post title
|
|
* @param {object} [notification.content] - { text, html } for mentions/replies
|
|
* @param {Date} notification.published - Activity timestamp (kept as Date for sort)
|
|
* @param {string} notification.createdAt - ISO string creation timestamp
|
|
* @returns {Promise<object>} Created or existing notification
|
|
*/
|
|
export async function addNotification(collections, notification) {
|
|
const { ap_notifications } = collections;
|
|
|
|
const result = await ap_notifications.updateOne(
|
|
{ uid: notification.uid },
|
|
{
|
|
$setOnInsert: {
|
|
...notification,
|
|
read: false,
|
|
},
|
|
},
|
|
{ upsert: true },
|
|
);
|
|
|
|
if (result.upsertedCount > 0) {
|
|
return await ap_notifications.findOne({ uid: notification.uid });
|
|
}
|
|
|
|
// Return existing document if it was a duplicate
|
|
return await ap_notifications.findOne({ uid: notification.uid });
|
|
}
|
|
|
|
/**
|
|
* Get notifications with cursor-based pagination
|
|
* @param {object} collections - MongoDB collections
|
|
* @param {object} options - Query options
|
|
* @param {string} [options.before] - Before cursor (published date)
|
|
* @param {number} [options.limit=20] - Items per page
|
|
* @param {boolean} [options.unreadOnly=false] - Show only unread notifications
|
|
* @param {string} [options.type] - Filter by notification type (like, boost, follow, reply, mention)
|
|
* @returns {Promise<object>} { items, before }
|
|
*/
|
|
export async function getNotifications(collections, options = {}) {
|
|
const { ap_notifications } = collections;
|
|
const parsedLimit = Number.parseInt(options.limit, 10);
|
|
const limit = Math.min(
|
|
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20,
|
|
100,
|
|
);
|
|
|
|
const query = {};
|
|
|
|
// Type filter
|
|
if (options.type) {
|
|
// "reply" tab shows replies only; mentions have their own "mention" tab
|
|
// "follow" tab shows both follows and follow_requests
|
|
if (options.type === "reply") {
|
|
query.type = "reply";
|
|
} else if (options.type === "follow") {
|
|
query.type = { $in: ["follow", "follow_request"] };
|
|
} else {
|
|
query.type = options.type;
|
|
}
|
|
}
|
|
|
|
// Unread filter
|
|
if (options.unreadOnly) {
|
|
query.read = false;
|
|
}
|
|
|
|
// Cursor pagination — published is stored as ISO string, so compare
|
|
// as strings (lexicographic ISO 8601 comparison is correct for dates)
|
|
if (options.before) {
|
|
query.published = { $lt: options.before };
|
|
}
|
|
|
|
const rawItems = await ap_notifications
|
|
.find(query)
|
|
.sort({ published: -1 })
|
|
.limit(limit)
|
|
.toArray();
|
|
|
|
// Normalize published dates to ISO strings for Nunjucks | date filter
|
|
const items = rawItems.map((item) => ({
|
|
...item,
|
|
published: item.published instanceof Date
|
|
? item.published.toISOString()
|
|
: item.published,
|
|
}));
|
|
|
|
// Generate cursor for next page (only if full page returned = more may exist)
|
|
const before =
|
|
items.length === limit
|
|
? items[items.length - 1].published
|
|
: null;
|
|
|
|
return {
|
|
items,
|
|
before,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get notification counts grouped by type
|
|
* @param {object} collections - MongoDB collections
|
|
* @param {boolean} [unreadOnly=false] - Count only unread notifications
|
|
* @returns {Promise<object>} Counts per type { all, reply, like, boost, follow }
|
|
*/
|
|
export async function getNotificationCountsByType(collections, unreadOnly = false) {
|
|
const { ap_notifications } = collections;
|
|
const matchStage = unreadOnly ? { $match: { read: false } } : { $match: {} };
|
|
|
|
const pipeline = [
|
|
matchStage,
|
|
{ $group: { _id: "$type", count: { $sum: 1 } } },
|
|
];
|
|
|
|
const results = await ap_notifications.aggregate(pipeline).toArray();
|
|
|
|
const counts = { all: 0, reply: 0, mention: 0, like: 0, boost: 0, follow: 0, dm: 0, report: 0 };
|
|
for (const { _id, count } of results) {
|
|
counts.all += count;
|
|
if (_id === "reply") {
|
|
counts.reply += count;
|
|
} else if (_id === "mention") {
|
|
counts.mention += count;
|
|
} else if (_id === "follow_request") {
|
|
counts.follow += count;
|
|
} else if (counts[_id] !== undefined) {
|
|
counts[_id] = count;
|
|
}
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
/**
|
|
* Get count of unread notifications
|
|
* @param {object} collections - MongoDB collections
|
|
* @returns {Promise<number>} Unread notification count
|
|
*/
|
|
export async function getUnreadNotificationCount(collections) {
|
|
const { ap_notifications } = collections;
|
|
return await ap_notifications.countDocuments({ read: false });
|
|
}
|
|
|
|
/**
|
|
* Mark notifications as read
|
|
* @param {object} collections - MongoDB collections
|
|
* @param {string[]} uids - Notification UIDs to mark read
|
|
* @returns {Promise<object>} Update result
|
|
*/
|
|
export async function markNotificationsRead(collections, uids) {
|
|
const { ap_notifications } = collections;
|
|
return await ap_notifications.updateMany(
|
|
{ uid: { $in: uids } },
|
|
{ $set: { read: true } },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Mark all notifications as read
|
|
* @param {object} collections - MongoDB collections
|
|
* @returns {Promise<object>} Update result
|
|
*/
|
|
export async function markAllNotificationsRead(collections) {
|
|
const { ap_notifications } = collections;
|
|
return await ap_notifications.updateMany({}, { $set: { read: true } });
|
|
}
|
|
|
|
/**
|
|
* Delete all notifications
|
|
* @param {object} collections - MongoDB collections
|
|
* @returns {Promise<object>} Delete result
|
|
*/
|
|
export async function clearAllNotifications(collections) {
|
|
const { ap_notifications } = collections;
|
|
return await ap_notifications.deleteMany({});
|
|
}
|
|
|
|
/**
|
|
* Delete a single notification by UID
|
|
* @param {object} collections - MongoDB collections
|
|
* @param {string} uid - Notification UID
|
|
* @returns {Promise<object>} Delete result
|
|
*/
|
|
export async function deleteNotification(collections, uid) {
|
|
const { ap_notifications } = collections;
|
|
return await ap_notifications.deleteOne({ uid });
|
|
}
|
|
|
|
/**
|
|
* Get direct message conversations grouped by peer actor.
|
|
* Returns conversations sorted by most-recent message, newest first.
|
|
* @param {object} collections - MongoDB collections
|
|
* @returns {Promise<Array>} Array of conversation objects
|
|
*/
|
|
export async function getDirectConversations(collections) {
|
|
const { ap_notifications } = collections;
|
|
|
|
const rawItems = await ap_notifications
|
|
.find({ isDirect: true })
|
|
.sort({ published: -1 })
|
|
.limit(200)
|
|
.toArray();
|
|
|
|
// Group by peer (senderActorUrl or actorUrl)
|
|
const byPeer = new Map();
|
|
for (const item of rawItems) {
|
|
const peerUrl = item.senderActorUrl || item.actorUrl || "";
|
|
if (!peerUrl) continue;
|
|
if (!byPeer.has(peerUrl)) {
|
|
byPeer.set(peerUrl, {
|
|
peerUrl,
|
|
peerName: item.actorName || "",
|
|
peerHandle: item.actorHandle || "",
|
|
peerPhoto: item.actorPhoto || "",
|
|
hasUnread: false,
|
|
messages: [],
|
|
});
|
|
}
|
|
const conv = byPeer.get(peerUrl);
|
|
if (!item.read) conv.hasUnread = true;
|
|
conv.messages.push({
|
|
direction: "inbound",
|
|
url: item.url || "",
|
|
content: item.content || {},
|
|
published:
|
|
item.published instanceof Date
|
|
? item.published.toISOString()
|
|
: item.published || "",
|
|
});
|
|
}
|
|
|
|
// Each conversation's messages arrived newest-first; reverse to chrono order
|
|
const conversations = [...byPeer.values()];
|
|
for (const conv of conversations) {
|
|
conv.messages.reverse();
|
|
}
|
|
|
|
// Sort conversations by most-recent message (newest first)
|
|
conversations.sort((a, b) => {
|
|
const aLast = a.messages.at(-1)?.published || "";
|
|
const bLast = b.messages.at(-1)?.published || "";
|
|
return bLast < aLast ? -1 : bLast > aLast ? 1 : 0;
|
|
});
|
|
|
|
return conversations;
|
|
}
|