mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
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.
424 lines
12 KiB
JavaScript
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;
|
|
}
|