mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 07:44:56 +02:00
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:
9
index.js
9
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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user