mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +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,
|
instanceCheckApiController,
|
||||||
popularAccountsApiController,
|
popularAccountsApiController,
|
||||||
} from "./lib/controllers/explore.js";
|
} 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 {
|
import {
|
||||||
listTabsController,
|
listTabsController,
|
||||||
addTabController,
|
addTabController,
|
||||||
@@ -296,6 +301,8 @@ export default class ActivityPubEndpoint {
|
|||||||
router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp));
|
router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp));
|
||||||
router.post("/admin/reader/follow-tag", followTagController(mp));
|
router.post("/admin/reader/follow-tag", followTagController(mp));
|
||||||
router.post("/admin/reader/unfollow-tag", unfollowTagController(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.get("/admin/reader/notifications", notificationsController(mp));
|
||||||
router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
|
router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
|
||||||
router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
|
router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { validateToken } from "../csrf.js";
|
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) {
|
export function followTagController(mountPath) {
|
||||||
return async (request, response, next) => {
|
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"),
|
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");
|
const followedTagsCol = application?.collections?.get("ap_followed_tags");
|
||||||
let isFollowed = false;
|
let isFollowed = false;
|
||||||
|
let isGloballyFollowed = false;
|
||||||
if (followedTagsCol) {
|
if (followedTagsCol) {
|
||||||
const followed = await followedTagsCol.findOne({
|
const followed = await followedTagsCol.findOne({
|
||||||
tag: { $regex: new RegExp(`^${tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }
|
tag: { $regex: new RegExp(`^${tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }
|
||||||
});
|
});
|
||||||
isFollowed = !!followed;
|
isFollowed = !!(followed?.followedAt);
|
||||||
|
isGloballyFollowed = !!(followed?.globalFollow);
|
||||||
}
|
}
|
||||||
|
|
||||||
const csrfToken = getToken(request.session);
|
const csrfToken = getToken(request.session);
|
||||||
|
const error = typeof request.query.error === "string" ? request.query.error : null;
|
||||||
|
|
||||||
response.render("activitypub-tag-timeline", {
|
response.render("activitypub-tag-timeline", {
|
||||||
title: `#${tag}`,
|
title: `#${tag}`,
|
||||||
@@ -68,6 +71,8 @@ export function tagTimelineController(mountPath) {
|
|||||||
csrfToken,
|
csrfToken,
|
||||||
mountPath,
|
mountPath,
|
||||||
isFollowed,
|
isFollowed,
|
||||||
|
isGloballyFollowed,
|
||||||
|
error,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { serializeStatus } from "../entities/status.js";
|
import { serializeStatus } from "../entities/status.js";
|
||||||
import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.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
|
const router = express.Router(); // eslint-disable-line new-cap
|
||||||
|
|
||||||
@@ -276,8 +277,29 @@ router.get("/api/v1/featured_tags", (req, res) => {
|
|||||||
|
|
||||||
// ─── Followed tags ──────────────────────────────────────────────────────────
|
// ─── Followed tags ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/api/v1/followed_tags", (req, res) => {
|
router.get("/api/v1/followed_tags", async (req, res, next) => {
|
||||||
res.json([]);
|
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 ────────────────────────────────────────────────────────────
|
// ─── Suggestions ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ export async function getFollowedTags(collections) {
|
|||||||
return docs.map((d) => d.tag);
|
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
|
* Follow a hashtag
|
||||||
* @param {object} collections - MongoDB collections
|
* @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 {object} collections - MongoDB collections
|
||||||
* @param {string} tag - Hashtag string (without # prefix)
|
* @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) {
|
export async function unfollowTag(collections, tag) {
|
||||||
const { ap_followed_tags } = collections;
|
const { ap_followed_tags } = collections;
|
||||||
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
const normalizedTag = tag.toLowerCase().trim().replace(/^#/, "");
|
||||||
if (!normalizedTag) return false;
|
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 });
|
const result = await ap_followed_tags.deleteOne({ tag: normalizedTag });
|
||||||
return result.deletedCount > 0;
|
return result.deletedCount > 0;
|
||||||
}
|
}
|
||||||
@@ -63,3 +89,61 @@ export async function isTagFollowed(collections, tag) {
|
|||||||
const doc = await ap_followed_tags.findOne({ tag: normalizedTag });
|
const doc = await ap_followed_tags.findOne({ tag: normalizedTag });
|
||||||
return !!doc;
|
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.",
|
"noPosts": "No posts found with #%s in your timeline.",
|
||||||
"followTag": "Follow hashtag",
|
"followTag": "Follow hashtag",
|
||||||
"unfollowTag": "Unfollow 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": {
|
"pagination": {
|
||||||
"newer": "← Newer",
|
"newer": "← Newer",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"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.",
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
{# Tag header #}
|
{# Tag header #}
|
||||||
<header class="ap-tag-header">
|
<header class="ap-tag-header">
|
||||||
<div class="ap-tag-header__info">
|
<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">
|
<p class="ap-tag-header__count">
|
||||||
{{ __("activitypub.reader.tagTimeline.postsTagged", items.length) }}
|
{{ __("activitypub.reader.tagTimeline.postsTagged", items.length) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ap-tag-header__actions">
|
<div class="ap-tag-header__actions">
|
||||||
|
{% if error %}
|
||||||
|
<p class="ap-tag-header__error">{{ __("activitypub.reader.tagTimeline.globalFollowError", error) }}</p>
|
||||||
|
{% endif %}
|
||||||
{% if isFollowed %}
|
{% if isFollowed %}
|
||||||
<form action="{{ mountPath }}/admin/reader/unfollow-tag" method="post" class="ap-tag-header__follow-form">
|
<form action="{{ mountPath }}/admin/reader/unfollow-tag" method="post" class="ap-tag-header__follow-form">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
||||||
@@ -27,6 +30,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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">
|
<a href="{{ mountPath }}/admin/reader" class="ap-tag-header__back">
|
||||||
← {{ __("activitypub.reader.title") }}
|
← {{ __("activitypub.reader.title") }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user