/** * Channel storage operations * @module storage/channels */ import { ObjectId } from "mongodb"; import { generateChannelUid } from "../utils/jf2.js"; /** * Channel color palette for visual identification. * Colors chosen for accessibility on white/light backgrounds as 4px left borders. */ const CHANNEL_COLORS = [ "#4A90D9", // blue "#E5604E", // red "#50B86C", // green "#E8A838", // amber "#9B59B6", // purple "#00B8D4", // cyan "#F06292", // pink "#78909C", // blue-grey "#FF7043", // deep orange "#26A69A", // teal ]; /** * Get a color for a channel based on its order * @param {number} order - Channel order index * @returns {string} Hex color */ export function getChannelColor(order) { return CHANNEL_COLORS[Math.abs(order) % CHANNEL_COLORS.length]; } import { deleteFeedsForChannel } from "./feeds.js"; import { deleteItemsForChannel } from "./items.js"; /** * Get channels collection from application * @param {object} application - Indiekit application * @returns {object} MongoDB collection */ function getCollection(application) { return application.collections.get("microsub_channels"); } /** * Get items collection for unread counts * @param {object} application - Indiekit application * @returns {object} MongoDB collection */ function getItemsCollection(application) { return application.collections.get("microsub_items"); } /** * Create a new channel * @param {object} application - Indiekit application * @param {object} data - Channel data * @param {string} data.name - Channel name * @param {string} [data.userId] - User ID * @returns {Promise} Created channel */ export async function createChannel(application, { name, userId }) { const collection = getCollection(application); // Generate unique UID with retry on collision let uid; let attempts = 0; const maxAttempts = 5; while (attempts < maxAttempts) { uid = generateChannelUid(); const existing = await collection.findOne({ uid }); if (!existing) break; attempts++; } if (attempts >= maxAttempts) { throw new Error("Failed to generate unique channel UID"); } // Get max order for user const maxOrderResult = await collection .find({ userId }) // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort .sort({ order: -1 }) .limit(1) .toArray(); const order = maxOrderResult.length > 0 ? maxOrderResult[0].order + 1 : 0; const color = getChannelColor(order); const channel = { uid, name, userId, order, color, settings: { excludeTypes: [], excludeRegex: undefined, }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await collection.insertOne(channel); return channel; } // Retention period for unread count (only count recent items) const UNREAD_RETENTION_DAYS = 30; /** * Get all channels for a user * @param {object} application - Indiekit application * @param {string} [userId] - User ID (optional for single-user mode) * @returns {Promise} Array of channels with unread counts */ export async function getChannels(application, userId) { const collection = getCollection(application); const itemsCollection = getItemsCollection(application); const filter = userId ? { userId } : {}; const channels = await collection // eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object .find(filter) // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort .sort({ order: 1 }) .toArray(); // Calculate cutoff date for unread counts (only count recent items) const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS); // Get unread counts for each channel (only recent items) const channelsWithCounts = await Promise.all( channels.map(async (channel) => { const unreadCount = await itemsCollection.countDocuments({ channelId: channel._id, readBy: { $ne: userId }, published: { $gte: cutoffDate }, _stripped: { $ne: true }, }); return { uid: channel.uid, name: channel.name, unread: unreadCount > 0 ? unreadCount : false, }; }), ); // Always include notifications channel first const notificationsChannel = channelsWithCounts.find( (c) => c.uid === "notifications", ); const otherChannels = channelsWithCounts.filter( (c) => c.uid !== "notifications", ); if (notificationsChannel) { return [notificationsChannel, ...otherChannels]; } return channelsWithCounts; } /** * Get channels with color field ensured (fallback for older channels without color). * Returns full channel documents with _id, unlike getChannels() which returns simplified objects. * @param {object} application - Indiekit application * @param {string} [userId] - User ID * @returns {Promise} Channels with color and unread fields */ export async function getChannelsWithColors(application, userId) { const collection = getCollection(application); const itemsCollection = getItemsCollection(application); const filter = userId ? { userId } : {}; const channels = await collection // eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object .find(filter) // eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort .sort({ order: 1 }) .toArray(); const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - UNREAD_RETENTION_DAYS); const enriched = await Promise.all( channels.map(async (channel, index) => { const unreadCount = await itemsCollection.countDocuments({ channelId: channel._id, readBy: { $ne: userId }, published: { $gte: cutoffDate }, _stripped: { $ne: true }, }); return { ...channel, color: channel.color || getChannelColor(index), unread: unreadCount > 0 ? unreadCount : false, }; }), ); // Notifications first, then by order const notifications = enriched.find((c) => c.uid === "notifications"); const others = enriched.filter((c) => c.uid !== "notifications"); if (notifications) { return [notifications, ...others]; } return enriched; } /** * Get a single channel by UID * @param {object} application - Indiekit application * @param {string} uid - Channel UID * @param {string} [userId] - User ID * @returns {Promise} Channel or null */ export async function getChannel(application, uid, userId) { const collection = getCollection(application); const query = { uid }; if (userId) query.userId = userId; return collection.findOne(query); } /** * Get channel by MongoDB ObjectId * @param {object} application - Indiekit application * @param {ObjectId|string} id - Channel ObjectId * @returns {Promise} Channel or null */ export async function getChannelById(application, id) { const collection = getCollection(application); const objectId = typeof id === "string" ? new ObjectId(id) : id; return collection.findOne({ _id: objectId }); } /** * Update a channel * @param {object} application - Indiekit application * @param {string} uid - Channel UID * @param {object} updates - Fields to update * @param {string} [userId] - User ID * @returns {Promise} Updated channel */ export async function updateChannel(application, uid, updates, userId) { const collection = getCollection(application); const query = { uid }; if (userId) query.userId = userId; const result = await collection.findOneAndUpdate( query, { $set: { ...updates, updatedAt: new Date().toISOString(), }, }, { returnDocument: "after" }, ); return result; } /** * Delete a channel and all its feeds and items * @param {object} application - Indiekit application * @param {string} uid - Channel UID * @param {string} [userId] - User ID * @returns {Promise} True if deleted */ export async function deleteChannel(application, uid, userId) { const collection = getCollection(application); const query = { uid }; if (userId) query.userId = userId; // Don't allow deleting system channels if (uid === "notifications" || uid === "activitypub") { return false; } // Find the channel first to get its ObjectId const channel = await collection.findOne(query); if (!channel) { return false; } // Cascade delete: items first, then feeds, then channel const itemsDeleted = await deleteItemsForChannel(application, channel._id); const feedsDeleted = await deleteFeedsForChannel(application, channel._id); console.info( `[Microsub] Deleted channel ${uid}: ${feedsDeleted} feeds, ${itemsDeleted} items`, ); const result = await collection.deleteOne({ _id: channel._id }); return result.deletedCount > 0; } /** * Reorder channels * @param {object} application - Indiekit application * @param {Array} channelUids - Ordered array of channel UIDs * @param {string} [userId] - User ID * @returns {Promise} */ export async function reorderChannels(application, channelUids, userId) { const collection = getCollection(application); // Update order for each channel const operations = channelUids.map((uid, index) => ({ updateOne: { filter: userId ? { uid, userId } : { uid }, update: { $set: { order: index, updatedAt: new Date().toISOString() } }, }, })); if (operations.length > 0) { await collection.bulkWrite(operations); } } /** * Update channel settings * @param {object} application - Indiekit application * @param {string} uid - Channel UID * @param {object} settings - Settings to update * @param {Array} [settings.excludeTypes] - Types to exclude * @param {string} [settings.excludeRegex] - Regex pattern to exclude * @param {string} [userId] - User ID * @returns {Promise} Updated channel */ export async function updateChannelSettings( application, uid, settings, userId, ) { return updateChannel(application, uid, { settings }, userId); } /** * Ensure notifications channel exists * @param {object} application - Indiekit application * @param {string} [userId] - User ID * @returns {Promise} Notifications channel */ export async function ensureNotificationsChannel(application, userId) { const collection = getCollection(application); const existing = await collection.findOne({ uid: "notifications", ...(userId && { userId }), }); if (existing) { return existing; } // Create notifications channel const channel = { uid: "notifications", name: "Notifications", userId, order: -1, // Always first settings: { excludeTypes: [], excludeRegex: undefined, }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await collection.insertOne(channel); return channel; } /** * Ensure ActivityPub channel exists * @param {object} application - Indiekit application * @param {string} [userId] - User ID * @returns {Promise} ActivityPub channel */ export async function ensureActivityPubChannel(application, userId) { const collection = getCollection(application); const existing = await collection.findOne({ uid: "activitypub", ...(userId && { userId }), }); if (existing) { return existing; } const channel = { uid: "activitypub", name: "Fediverse", userId, source: "activitypub", order: -0.5, // After notifications (-1), before user channels (0+) settings: { excludeTypes: [], excludeRegex: undefined, }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await collection.insertOne(channel); return channel; }