feat: tags.pub global hashtag discovery integration (v3.8.0)

- Add setGlobalFollow/removeGlobalFollow/getFollowedTagsWithState to
  followed-tags storage; unfollowTag now preserves global follow state
- Add followTagGloballyController/unfollowTagGloballyController that
  send AP Follow/Undo via Fedify to tags.pub actor URLs
- Register POST /admin/reader/follow-tag-global and unfollow-tag-global
  routes with plugin reference for Fedify access
- Tag timeline controller passes isGloballyFollowed + error query param
- Tag timeline template adds global follow/unfollow buttons with globe
  indicator and inline error display
- Wire GET /api/v1/followed_tags to return real data with globalFollow state
- Add i18n keys: followGlobally, unfollowGlobally, globallyFollowing,
  globalFollowError
This commit is contained in:
Ricardo
2026-03-22 00:22:47 +01:00
committed by svemagie
parent 97a902bda1
commit a84c6f1abd
8 changed files with 237 additions and 11 deletions

View File

@@ -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,
@@ -392,6 +397,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));

View File

@@ -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);
}
};
}

View File

@@ -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);

View File

@@ -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 ────────────────────────────────────────────────────────────

View File

@@ -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<Array<{tag: string, followedAt?: string, globalFollow?: boolean, globalActorUrl?: string}>>}
*/
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<boolean>} true if removed, false if not found
* @returns {Promise<boolean>} 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<void>}
*/
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<void>}
*/
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 });
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -4,12 +4,15 @@
{# Tag header #}
<header class="ap-tag-header">
<div class="ap-tag-header__info">
<h2 class="ap-tag-header__title">#{{ hashtag }}</h2>
<h2 class="ap-tag-header__title">#{{ hashtag }}{% if isGloballyFollowed %} <span class="ap-tag-header__global-badge" title="{{ __('activitypub.reader.tagTimeline.globallyFollowing') }}">🌐</span>{% endif %}</h2>
<p class="ap-tag-header__count">
{{ __("activitypub.reader.tagTimeline.postsTagged", items.length) }}
</p>
</div>
<div class="ap-tag-header__actions">
{% if error %}
<p class="ap-tag-header__error">{{ __("activitypub.reader.tagTimeline.globalFollowError", error) }}</p>
{% endif %}
{% if isFollowed %}
<form action="{{ mountPath }}/admin/reader/unfollow-tag" method="post" class="ap-tag-header__follow-form">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
@@ -27,6 +30,23 @@
</button>
</form>
{% endif %}
{% if isGloballyFollowed %}
<form action="{{ mountPath }}/admin/reader/unfollow-tag-global" method="post" class="ap-tag-header__follow-form">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input type="hidden" name="tag" value="{{ hashtag }}">
<button type="submit" class="ap-tag-header__unfollow-btn ap-tag-header__unfollow-btn--global">
🌐 {{ __("activitypub.reader.tagTimeline.unfollowGlobally") }}
</button>
</form>
{% else %}
<form action="{{ mountPath }}/admin/reader/follow-tag-global" method="post" class="ap-tag-header__follow-form">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input type="hidden" name="tag" value="{{ hashtag }}">
<button type="submit" class="ap-tag-header__follow-btn ap-tag-header__follow-btn--global">
🌐 {{ __("activitypub.reader.tagTimeline.followGlobally") }}
</button>
</form>
{% endif %}
<a href="{{ mountPath }}/admin/reader" class="ap-tag-header__back">
← {{ __("activitypub.reader.title") }}
</a>