Followed hashtags, and a tag's week of use

tags/:name shows the tag's last seven days in public posts PrivaPub holds
(uses and authors, as Mastodon gives them) and whether the persona follows
it; follow, unfollow and followed_tags replace the stubs. A public post that
is neither a boost nor a reply comes, as it arrives, to the homes of those
following one of its tags. Nothing is fetched for a followed tag and no
other server hears of it. Posts are indexed by tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 03:04:27 +02:00
1 parent 864dae4469
commit 7bfd7ce285
9 files changed
+229 -13

No files matched your search

@@ -105,9 +105,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v2/suggestions"), Scope("read")]
public IActionResult SuggestionsV2() => Json(Array.Empty<object>());
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
public IActionResult FollowedTags() => Json(Array.Empty<object>());
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
public IActionResult Endorsements() => Json(Array.Empty<object>());
@@ -138,14 +135,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/{id}/identity_proofs"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult IdentityProofs(string id) => Json(Array.Empty<object>());
[HttpGet("/api/v1/tags/{name}"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Tag(string name)
{
var tag = name?.TrimStart('#').ToLowerInvariant() ?? string.Empty;
var baseAddress = HttpContext.RequestServices.GetRequiredService<Federation.Actors.ILocalActorService>().BaseAddress;
return Json(new { name = tag, url = $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty<object>(), following = false });
}
[HttpGet("/api/v1/instance/languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Languages() => Json(InstanceController.LanguageCodes(HttpContext.RequestServices)
.Select(code => new { code, name = System.Globalization.CultureInfo.GetCultureInfo(code).EnglishName }));
@@ -0,0 +1,110 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Hashtags as Mastodon shows them (owner decision 2026-10-04, followed tags back from the cut list): a tag with the
// last week of its use in public posts PrivaPub holds, and whether the persona follows it; following one brings its
// public posts to the persona's home as they arrive (Fanout).
public class TagsController : MastodonController
{
const int MaxFollowed = 500;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public TagsController(DbEntities dbEntities, ILocalActorService localActors)
{
_dbEntities = dbEntities;
_localActors = localActors;
}
public static string Normalise(string name) => name?.Trim().TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant() ?? string.Empty;
// a day's uses and authors, newest first, for the last week, as Mastodon gives them (strings, days as unix time)
public static async Task<List<object>> History(DbEntities dbEntities, string tag, CancellationToken token)
{
var today = DateTime.UtcNow.Date;
var since = today.AddDays(-6);
var posts = await dbEntities.Posts
.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.CreationDate >= since)
.Project(p => p.Include(x => x.CreationDate).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId))
.ExecuteAsync(token);
return Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day =>
{
var that = posts.Where(p => p.CreationDate >= day && p.CreationDate < day.AddDays(1)).ToList();
return (object)new
{
day = new DateTimeOffset(day, TimeSpan.Zero).ToUnixTimeSeconds().ToString(),
uses = that.Count.ToString(),
accounts = that.Select(p => p.AuthorAccountId ?? p.GroupUserId).Distinct().Count().ToString()
};
}).ToList();
}
async Task<object> View(string tag, bool? following, CancellationToken token) => new
{
name = tag,
url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}",
history = await History(_dbEntities, tag, token),
following = following ?? (MyId != default && await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId && t.Name == tag).ExecuteAnyAsync(token))
};
[HttpGet("/api/v1/tags/{name}"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Tag(string name, CancellationToken token)
{
var tag = Normalise(name);
return tag.Length == 0 ? NotFoundError() : Json(await View(tag, default, token));
}
[HttpPost("/api/v1/tags/{name}/follow"), Scope("write:follows")]
public async Task<IActionResult> Follow(string name, CancellationToken token)
{
var tag = Normalise(name);
if (tag.Length == 0 || tag.Length > 100)
return NotFoundError();
if (!await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId && t.Name == tag).ExecuteAnyAsync(token))
{
if (await DB.Default.CountAsync<FollowedTag>(t => t.AvatarId == MyId, token) >= MaxFollowed)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many followed hashtags");
try
{
await DB.Default.SaveAsync(new FollowedTag { AvatarId = MyId, Name = tag }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
}
return Json(await View(tag, true, token));
}
[HttpPost("/api/v1/tags/{name}/unfollow"), Scope("write:follows")]
public async Task<IActionResult> Unfollow(string name, CancellationToken token)
{
var tag = Normalise(name);
await DB.Default.DeleteAsync<FollowedTag>(t => t.AvatarId == MyId && t.Name == tag);
return Json(await View(tag, false, token));
}
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
public async Task<IActionResult> Followed(CancellationToken token)
{
var page = await Page.From(Params, Limit(100, 200)).Fetch(DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId), t => t.ID, token);
Link("/api/v1/followed_tags", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
var views = new List<object>();
foreach (var followed in page)
views.Add(await View(followed.Name, true, token));
return Json(views);
}
}
}