diff --git a/index.js b/index.js index de05988..6bfd41c 100644 --- a/index.js +++ b/index.js @@ -79,7 +79,12 @@ import { instanceCheckApiController, popularAccountsApiController, } from "./lib/controllers/explore.js"; -import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js"; +import { + followTagController, + unfollowTagController, + followTagGloballyController, + unfollowTagGloballyController, +} from "./lib/controllers/follow-tag.js"; import { listTabsController, addTabController, @@ -296,6 +301,8 @@ export default class ActivityPubEndpoint { router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp)); router.post("/admin/reader/follow-tag", followTagController(mp)); router.post("/admin/reader/unfollow-tag", unfollowTagController(mp)); + router.post("/admin/reader/follow-tag-global", followTagGloballyController(mp, this)); + router.post("/admin/reader/unfollow-tag-global", unfollowTagGloballyController(mp, this)); router.get("/admin/reader/notifications", notificationsController(mp)); router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp)); router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp)); diff --git a/lib/controllers/follow-tag.js b/lib/controllers/follow-tag.js index 98f0e18..1b1adf5 100644 --- a/lib/controllers/follow-tag.js +++ b/lib/controllers/follow-tag.js @@ -3,7 +3,13 @@ */ import { validateToken } from "../csrf.js"; -import { followTag, unfollowTag } from "../storage/followed-tags.js"; +import { + followTag, + unfollowTag, + setGlobalFollow, + removeGlobalFollow, + getTagsPubActorUrl, +} from "../storage/followed-tags.js"; export function followTagController(mountPath) { return async (request, response, next) => { @@ -60,3 +66,81 @@ export function unfollowTagController(mountPath) { } }; } + +export function followTagGloballyController(mountPath, plugin) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + + // CSRF validation + if (!validateToken(request)) { + return response.status(403).json({ error: "Invalid CSRF token" }); + } + + const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : ""; + if (!tag) { + return response.redirect(`${mountPath}/admin/reader`); + } + + const actorUrl = getTagsPubActorUrl(tag); + + // Send AP Follow activity via Fedify + const result = await plugin.followActor(actorUrl); + if (!result.ok) { + const errorMsg = encodeURIComponent(result.error || "Follow failed"); + return response.redirect( + `${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}&error=${errorMsg}` + ); + } + + // Store global follow state + const collections = { + ap_followed_tags: application?.collections?.get("ap_followed_tags"), + }; + await setGlobalFollow(collections, tag, actorUrl); + + return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`); + } catch (error) { + next(error); + } + }; +} + +export function unfollowTagGloballyController(mountPath, plugin) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + + // CSRF validation + if (!validateToken(request)) { + return response.status(403).json({ error: "Invalid CSRF token" }); + } + + const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : ""; + if (!tag) { + return response.redirect(`${mountPath}/admin/reader`); + } + + const actorUrl = getTagsPubActorUrl(tag); + + // Send AP Undo(Follow) activity via Fedify + const result = await plugin.unfollowActor(actorUrl); + if (!result.ok) { + const errorMsg = encodeURIComponent(result.error || "Unfollow failed"); + return response.redirect( + `${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}&error=${errorMsg}` + ); + } + + // Remove global follow state + const collections = { + ap_followed_tags: application?.collections?.get("ap_followed_tags"), + }; + await removeGlobalFollow(collections, tag); + + return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`); + } catch (error) { + next(error); + } + }; +} diff --git a/lib/controllers/tag-timeline.js b/lib/controllers/tag-timeline.js index b61b200..19ecdf8 100644 --- a/lib/controllers/tag-timeline.js +++ b/lib/controllers/tag-timeline.js @@ -45,17 +45,20 @@ export function tagTimelineController(mountPath) { interactionsCol: application?.collections?.get("ap_interactions"), }); - // Check if this hashtag is followed + // Check if this hashtag is followed (local and/or global) const followedTagsCol = application?.collections?.get("ap_followed_tags"); let isFollowed = false; + let isGloballyFollowed = false; if (followedTagsCol) { const followed = await followedTagsCol.findOne({ tag: { $regex: new RegExp(`^${tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") } }); - isFollowed = !!followed; + isFollowed = !!(followed?.followedAt); + isGloballyFollowed = !!(followed?.globalFollow); } const csrfToken = getToken(request.session); + const error = typeof request.query.error === "string" ? request.query.error : null; response.render("activitypub-tag-timeline", { title: `#${tag}`, @@ -68,6 +71,8 @@ export function tagTimelineController(mountPath) { csrfToken, mountPath, isFollowed, + isGloballyFollowed, + error, }); } catch (error) { next(error); diff --git a/lib/mastodon/routes/stubs.js b/lib/mastodon/routes/stubs.js index 70c9458..8aa063a 100644 --- a/lib/mastodon/routes/stubs.js +++ b/lib/mastodon/routes/stubs.js @@ -21,6 +21,7 @@ import express from "express"; import { serializeStatus } from "../entities/status.js"; import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.js"; +import { getFollowedTagsWithState } from "../../storage/followed-tags.js"; const router = express.Router(); // eslint-disable-line new-cap @@ -276,8 +277,29 @@ router.get("/api/v1/featured_tags", (req, res) => { // ─── Followed tags ────────────────────────────────────────────────────────── -router.get("/api/v1/followed_tags", (req, res) => { - res.json([]); +router.get("/api/v1/followed_tags", async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + if (!collections?.ap_followed_tags) { + return res.json([]); + } + + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + const publicationUrl = pluginOptions.publicationUrl || ""; + const tags = await getFollowedTagsWithState({ ap_followed_tags: collections.ap_followed_tags }); + + const response = tags.map((doc) => ({ + id: doc._id.toString(), + name: doc.tag, + url: `${publicationUrl.replace(/\/$/, "")}/tags/${doc.tag}`, + history: [], + following: true, + })); + + res.json(response); + } catch (error) { + next(error); + } }); // ─── Suggestions ──────────────────────────────────────────────────────────── diff --git a/lib/storage/followed-tags.js b/lib/storage/followed-tags.js index 53ec79d..079ca38 100644 --- a/lib/storage/followed-tags.js +++ b/lib/storage/followed-tags.js @@ -15,6 +15,17 @@ export async function getFollowedTags(collections) { return docs.map((d) => d.tag); } +/** + * Get all followed hashtags with full state (local + global follow tracking) + * @param {object} collections - MongoDB collections + * @returns {Promise>} + */ +export async function getFollowedTagsWithState(collections) { + const { ap_followed_tags } = collections; + if (!ap_followed_tags) return []; + return ap_followed_tags.find({}).sort({ followedAt: -1 }).toArray(); +} + /** * Follow a hashtag * @param {object} collections - MongoDB collections @@ -36,16 +47,31 @@ export async function followTag(collections, tag) { } /** - * Unfollow a hashtag + * Unfollow a hashtag locally. + * If a global follow (tags.pub) is active, preserves the document with global state intact. + * Only deletes the document entirely when no global follow is active. * @param {object} collections - MongoDB collections * @param {string} tag - Hashtag string (without # prefix) - * @returns {Promise} true if removed, false if not found + * @returns {Promise} true if removed/updated, false if not found */ export async function unfollowTag(collections, tag) { const { ap_followed_tags } = collections; const normalizedTag = tag.toLowerCase().trim().replace(/^#/, ""); if (!normalizedTag) return false; + // Check if a global follow is active before deleting + const existing = await ap_followed_tags.findOne({ tag: normalizedTag }); + if (!existing) return false; + + if (existing.globalFollow) { + // Preserve the document — only unset the local follow fields + await ap_followed_tags.updateOne( + { tag: normalizedTag }, + { $unset: { followedAt: "" } } + ); + return true; + } + const result = await ap_followed_tags.deleteOne({ tag: normalizedTag }); return result.deletedCount > 0; } @@ -63,3 +89,61 @@ export async function isTagFollowed(collections, tag) { const doc = await ap_followed_tags.findOne({ tag: normalizedTag }); return !!doc; } + +/** + * Returns the deterministic tags.pub actor URL for a hashtag. + * @param {string} tag - Hashtag string (without # prefix) + * @returns {string} Actor URL + */ +export function getTagsPubActorUrl(tag) { + return `https://tags.pub/user/${tag.toLowerCase()}`; +} + +/** + * Set global follow state for a hashtag (upsert — works even with no local follow). + * @param {object} collections - MongoDB collections + * @param {string} tag - Hashtag string (without # prefix) + * @param {string} actorUrl - The tags.pub actor URL + * @returns {Promise} + */ +export async function setGlobalFollow(collections, tag, actorUrl) { + const { ap_followed_tags } = collections; + const normalizedTag = tag.toLowerCase().trim().replace(/^#/, ""); + if (!normalizedTag) return; + + await ap_followed_tags.updateOne( + { tag: normalizedTag }, + { + $set: { globalFollow: true, globalActorUrl: actorUrl }, + $setOnInsert: { tag: normalizedTag }, + }, + { upsert: true } + ); +} + +/** + * Remove global follow state for a hashtag. + * If no local follow exists (no followedAt), deletes the document entirely. + * @param {object} collections - MongoDB collections + * @param {string} tag - Hashtag string (without # prefix) + * @returns {Promise} + */ +export async function removeGlobalFollow(collections, tag) { + const { ap_followed_tags } = collections; + const normalizedTag = tag.toLowerCase().trim().replace(/^#/, ""); + if (!normalizedTag) return; + + const existing = await ap_followed_tags.findOne({ tag: normalizedTag }); + if (!existing) return; + + if (existing.followedAt) { + // Local follow is still active — just unset the global fields + await ap_followed_tags.updateOne( + { tag: normalizedTag }, + { $unset: { globalFollow: "", globalActorUrl: "" } } + ); + } else { + // No local follow — delete the document entirely + await ap_followed_tags.deleteOne({ tag: normalizedTag }); + } +} diff --git a/locales/en.json b/locales/en.json index 98ce7da..e5931d2 100644 --- a/locales/en.json +++ b/locales/en.json @@ -297,7 +297,11 @@ "noPosts": "No posts found with #%s in your timeline.", "followTag": "Follow hashtag", "unfollowTag": "Unfollow hashtag", - "following": "Following" + "following": "Following", + "followGlobally": "Follow globally via tags.pub", + "unfollowGlobally": "Unfollow global", + "globallyFollowing": "Following globally", + "globalFollowError": "Failed to follow globally: %s" }, "pagination": { "newer": "← Newer", diff --git a/package.json b/package.json index 9798d17..e9d1d89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.7.5", + "version": "3.8.0", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-tag-timeline.njk b/views/activitypub-tag-timeline.njk index 6a9988f..416e594 100644 --- a/views/activitypub-tag-timeline.njk +++ b/views/activitypub-tag-timeline.njk @@ -4,12 +4,15 @@ {# Tag header #}
-

#{{ hashtag }}

+

#{{ hashtag }}{% if isGloballyFollowed %} 🌐{% endif %}

{{ __("activitypub.reader.tagTimeline.postsTagged", items.length) }}

+ {% if error %} +

{{ __("activitypub.reader.tagTimeline.globalFollowError", error) }}

+ {% endif %} {% if isFollowed %} {% endif %} + {% if isGloballyFollowed %} + + {% else %} + + {% endif %} ← {{ __("activitypub.reader.title") }}