Files
indiekit-endpoint-microsub/lib/storage/channels.js
Ricardo 26225f1f80 feat: add multi-view reader with Channels, Deck, and Timeline views
Three reader views accessible via icon toolbar:
- Channels: existing view (renamed), per-channel timelines
- Deck: TweetDeck-style configurable columns with compact cards
- Timeline: all channels merged chronologically with colored borders

Includes channel color palette, cross-channel query, deck config
storage, session-based view preference, and view switcher partial.
2026-02-26 14:42:00 +01:00

424 lines
12 KiB
JavaScript

/**
* 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<object>} 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>} 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<Array>} 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<object|null>} 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<object|null>} 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<object|null>} 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<boolean>} 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<void>}
*/
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<object|null>} 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<object>} 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<object>} 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;
}