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);
}
}
}
+8
View File
@@ -43,6 +43,14 @@ namespace PrivaPub.Domain.Timelines
if (!string.IsNullOrEmpty(post.AudienceURI))
foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token))
recipients.Add(member.AvatarId);
// a public post (not a boost, not a reply) comes to those following one of its hashtags, as on Mastodon
if (post.Visibility == PostVisibility.Public && post.Tags.Count > 0 && string.IsNullOrEmpty(post.ReblogOfPostId)
&& string.IsNullOrEmpty(post.InReplyToURI) && string.IsNullOrEmpty(post.AnsweringToPostId))
{
var tags = post.Tags.ToList();
foreach (var followed in await DB.Default.Find<FollowedTag>().Match(t => tags.Contains(t.Name)).ExecuteAsync(token))
recipients.Add(followed.AvatarId);
}
break;
case PostVisibility.Direct when !string.IsNullOrEmpty(post.ConversationId):
var conversation = await _dbEntities.DmGroups.MatchID(post.ConversationId).ExecuteFirstAsync(token);
+3
View File
@@ -95,6 +95,7 @@ namespace PrivaPub.Infrastructure.Data
foreach (var pair in new (Func<Task>, string)[]
{
(() => DB.Default.Index<Block>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "block"),
(() => DB.Default.Index<FollowedTag>().Key(t => t.AvatarId, KeyType.Ascending).Key(t => t.Name, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "followed tag"),
(() => DB.Default.Index<PersonaListMember>().Key(m => m.ListId, KeyType.Ascending).Key(m => m.AccountId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "list member"),
(() => DB.Default.Index<BlockedBy>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "blocked by"),
(() => DB.Default.Index<Mute>().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"),
@@ -107,6 +108,8 @@ namespace PrivaPub.Infrastructure.Data
await Plain<BlockedBy>(token, b => b.ActorURI);
await Plain<PersonaList>(token, l => l.AvatarId);
await Plain<PersonaFilter>(token, f => f.AvatarId);
await Plain<FollowedTag>(token, t => t.Name);
await Plain<Post>(token, p => p.Tags);
await Plain<PersonaListMember>(token, m => m.AvatarId);
await Plain<Mute>(token, m => m.TargetActorURI);
await Plain<Report>(token, r => r.IsResolved, r => r.ID);
+13
View File
@@ -0,0 +1,13 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
//a hashtag a persona follows (Mastodon's followed tags, owner decision 2026-10-04): public posts PrivaPub holds with it
//come to the persona's home as they arrive. Following one fetches nothing and tells no other server.
public class FollowedTag : Entity
{
public string AvatarId { get; set; }
public string Name { get; set; }//normalised: no #, NFKC, lower case
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+1
View File
@@ -65,6 +65,7 @@ namespace PrivaPub.Services
await DB.Default.DeleteAsync<Models.Social.PersonaListMember>(m => m.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.PersonaList>(l => l.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.PersonaFilter>(f => f.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.FollowedTag>(t => t.AvatarId == avatar.ID);
}
await DB.Default.Update<RootUser>().MatchID(root.ID)