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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user