Files
indiekit-endpoint-microsub/lib/controllers/timeline.js
Ricardo e48335da2c feat: mark source as read — split button with popover
Add ability to mark all items from a specific feed/source as read at once,
instead of clicking each item individually. The mark-read button becomes a
split button group with a caret that opens a popover offering "Mark [source]
as read". Items without a feedId (AP items) keep the simple button.

Confab-Link: http://localhost:8080/sessions/a477883d-4aef-4013-983c-ce3d3157cfba
2026-03-11 16:08:53 +01:00

153 lines
3.8 KiB
JavaScript

/**
* Timeline controller
* @module controllers/timeline
*/
import { IndiekitError } from "@indiekit/error";
import { proxyItemImages } from "../media/proxy.js";
import { getChannel, getChannelById } from "../storage/channels.js";
import {
getTimelineItems,
markFeedItemsRead,
markItemsRead,
markItemsUnread,
removeItems,
} from "../storage/items.js";
import { getUserId } from "../utils/auth.js";
import {
validateChannel,
validateEntries,
parseArrayParameter as parseArrayParametereter,
} from "../utils/validation.js";
/**
* Get timeline items for a channel
* GET ?action=timeline&channel=<uid>
* @param {object} request - Express request
* @param {object} response - Express response
*/
export async function get(request, response) {
const { application } = request.app.locals;
const userId = getUserId(request);
const { channel, before, after, limit } = request.query;
validateChannel(channel);
// Verify channel exists
const channelDocument = await getChannel(application, channel, userId);
if (!channelDocument) {
throw new IndiekitError("Channel not found", {
status: 404,
});
}
const timeline = await getTimelineItems(application, channelDocument._id, {
before,
after,
limit,
userId,
});
// Proxy images if application URL is available
const baseUrl = application.url;
if (baseUrl && timeline.items) {
timeline.items = timeline.items.map((item) =>
proxyItemImages(item, baseUrl),
);
}
response.json(timeline);
}
/**
* Handle timeline actions (mark_read, mark_unread, remove)
* POST ?action=timeline
* @param {object} request - Express request
* @param {object} response - Express response
*/
export async function action(request, response) {
const { application } = request.app.locals;
const userId = getUserId(request);
const { method, channel } = request.body;
validateChannel(channel);
// Verify channel exists — try by UID first, fall back to ObjectId
// (timeline view may send ObjectId string for items from orphan channels)
let channelDocument = await getChannel(application, channel, userId);
if (!channelDocument) {
try {
channelDocument = await getChannelById(application, channel);
} catch {
// Invalid ObjectId format — channel string is not a valid ObjectId
}
}
if (!channelDocument) {
throw new IndiekitError("Channel not found", {
status: 404,
});
}
// Get entry IDs from request
const entries = parseArrayParametereter(request.body, "entry");
switch (method) {
case "mark_read": {
validateEntries(entries);
const count = await markItemsRead(
application,
channelDocument._id,
entries,
userId,
);
return response.json({ result: "ok", updated: count });
}
case "mark_read_source": {
const feedId = request.body.feed;
if (!feedId) {
throw new IndiekitError("feed parameter required", {
status: 400,
});
}
const count = await markFeedItemsRead(
application,
channelDocument._id,
feedId,
userId,
);
return response.json({ result: "ok", updated: count });
}
case "mark_unread": {
validateEntries(entries);
const count = await markItemsUnread(
application,
channelDocument._id,
entries,
userId,
);
return response.json({ result: "ok", updated: count });
}
case "remove": {
validateEntries(entries);
const count = await removeItems(
application,
channelDocument._id,
entries,
);
return response.json({ result: "ok", removed: count });
}
default: {
throw new IndiekitError(`Invalid timeline method: ${method}`, {
status: 400,
});
}
}
}
export const timelineController = { get, action };