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