diff --git a/PrivaPub.Tests/Http/MastodonInstanceTests.cs b/PrivaPub.Tests/Http/MastodonInstanceTests.cs index 361d770..fdf874a 100644 --- a/PrivaPub.Tests/Http/MastodonInstanceTests.cs +++ b/PrivaPub.Tests/Http/MastodonInstanceTests.cs @@ -81,7 +81,6 @@ namespace PrivaPub.Tests.Http [Theory] [InlineData("/api/v1/suggestions")] [InlineData("/api/v2/suggestions")] - [InlineData("/api/v1/followed_tags")] [InlineData("/api/v1/endorsements")] [InlineData("/api/v1/featured_tags")] public async Task Signed_in_stub_routes_answer_empty_lists(string path) diff --git a/PrivaPub.Tests/Http/MastodonTagsTests.cs b/PrivaPub.Tests/Http/MastodonTagsTests.cs new file mode 100644 index 0000000..ad558f1 --- /dev/null +++ b/PrivaPub.Tests/Http/MastodonTagsTests.cs @@ -0,0 +1,89 @@ +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Http +{ + // Hashtags: a tag's week of use in public posts, and followed tags, whose public posts come to the persona's home + [Trait("Category", "Integration")] + public sealed class MastodonTagsTests : IAsyncLifetime + { + PrivaPubHost _host; + Peer _peer; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + _peer = await Peer.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_peer != default) + await _peer.DisposeAsync(); + } + + static string NewTag() => $"t{Guid.NewGuid():N}"[..12]; + + [Fact] + public async Task A_tag_shows_its_week_of_public_use_and_whether_the_persona_follows_it() + { + var alice = await _host.Mastodon("alice"); + var bob = await _host.Mastodon("bob"); + var tag = NewTag(); + await alice.Status($"about #{tag}"); + await alice.Status($"more about #{tag.ToUpperInvariant()}"); + await bob.Status($"also #{tag}"); + await bob.Status($"quietly #{tag}", ("visibility", "unlisted")); + + var shown = (await _host.Client().Get($"/api/v1/tags/{tag}")).Ok().Object; + Assert.Equal(tag, shown.Text("name")); + Assert.False(shown.Flag("following")); + var history = shown["history"]!.AsArray(); + Assert.Equal(7, history.Count); + Assert.Equal(("3", "2"), (history[0].Text("uses"), history[0].Text("accounts"))); + Assert.Equal(new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToUnixTimeSeconds().ToString(), history[0].Text("day")); + + Assert.True((await alice.Client.Post($"/api/v1/tags/{tag}/follow")).Ok().Body.Flag("following")); + Assert.True((await alice.Client.Get($"/api/v1/tags/%23{tag.ToUpperInvariant()}")).Ok().Body.Flag("following")); + Assert.Equal(new[] { tag }, (await alice.Client.Get("/api/v1/followed_tags")).Ok().Array.Select(t => t.Text("name"))); + Assert.False((await bob.Client.Get($"/api/v1/tags/{tag}")).Ok().Body.Flag("following")); + Assert.False((await alice.Client.Post($"/api/v1/tags/{tag}/unfollow")).Ok().Body.Flag("following")); + Assert.Empty((await alice.Client.Get("/api/v1/followed_tags")).Ok().Array); + Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Post($"/api/v1/tags/{tag}/follow")).Status); + } + + [Fact] + public async Task A_followed_tags_public_posts_come_home_from_here_and_from_other_servers() + { + var alice = await _host.Mastodon("alice"); + var bob = await _host.Mastodon("bob"); + var carol = await _host.Mastodon("carol"); + var remote = new RemoteActor(_peer, "dora"); + var tag = NewTag(); + (await alice.Client.Post($"/api/v1/tags/{tag}/follow")).Ok(); + + var open = (await bob.Status($"news about #{tag}")).Text("id"); + var quiet = (await bob.Status($"quiet news #{tag}", ("visibility", "unlisted"))).Text("id"); + var answer = (await bob.Status($"and #{tag} again", ("in_reply_to_id", open))).Text("id"); + var untagged = (await bob.Status("nothing tagged")).Text("id"); + var far = await _host.PublicPostFrom(remote, carol, $"

far away #{tag}

", note => note["tag"]!.AsArray().Add(new JsonObject + { + ["type"] = "Hashtag", + ["name"] = "#" + tag, + ["href"] = $"{remote.Origin()}/tags/{tag}" + })); + + var home = (await alice.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Ids.ToList(); + Assert.Contains(open, home); + Assert.Contains(far.ID, home); + Assert.DoesNotContain(quiet, home); + Assert.DoesNotContain(answer, home); + Assert.DoesNotContain(untagged, home); + Assert.DoesNotContain(open, (await carol.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Ids); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index c759e8f..35e1755 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -105,9 +105,6 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v2/suggestions"), Scope("read")] public IActionResult SuggestionsV2() => Json(Array.Empty()); - [HttpGet("/api/v1/followed_tags"), Scope("read:follows")] - public IActionResult FollowedTags() => Json(Array.Empty()); - [HttpGet("/api/v1/endorsements"), Scope("read:accounts")] public IActionResult Endorsements() => Json(Array.Empty()); @@ -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()); - [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().BaseAddress; - return Json(new { name = tag, url = $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty(), 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 })); diff --git a/PrivaPub/Api/Mastodon/Controllers/TagsController.cs b/PrivaPub/Api/Mastodon/Controllers/TagsController.cs new file mode 100644 index 0000000..058c779 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/TagsController.cs @@ -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> 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 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().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 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 Follow(string name, CancellationToken token) + { + var tag = Normalise(name); + if (tag.Length == 0 || tag.Length > 100) + return NotFoundError(); + if (!await DB.Default.Find().Match(t => t.AvatarId == MyId && t.Name == tag).ExecuteAnyAsync(token)) + { + if (await DB.Default.CountAsync(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 Unfollow(string name, CancellationToken token) + { + var tag = Normalise(name); + await DB.Default.DeleteAsync(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 Followed(CancellationToken token) + { + var page = await Page.From(Params, Limit(100, 200)).Fetch(DB.Default.Find().Match(t => t.AvatarId == MyId), t => t.ID, token); + Link("/api/v1/followed_tags", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID); + var views = new List(); + foreach (var followed in page) + views.Add(await View(followed.Name, true, token)); + return Json(views); + } + } +} diff --git a/PrivaPub/Domain/Timelines/Fanout.cs b/PrivaPub/Domain/Timelines/Fanout.cs index f50d186..5373dc6 100644 --- a/PrivaPub/Domain/Timelines/Fanout.cs +++ b/PrivaPub/Domain/Timelines/Fanout.cs @@ -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().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); diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 89d9be5..170d0f1 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -95,6 +95,7 @@ namespace PrivaPub.Infrastructure.Data foreach (var pair in new (Func, string)[] { (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "block"), + (() => DB.Default.Index().Key(t => t.AvatarId, KeyType.Ascending).Key(t => t.Name, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "followed tag"), (() => DB.Default.Index().Key(m => m.ListId, KeyType.Ascending).Key(m => m.AccountId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "list member"), (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "blocked by"), (() => DB.Default.Index().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(token, b => b.ActorURI); await Plain(token, l => l.AvatarId); await Plain(token, f => f.AvatarId); + await Plain(token, t => t.Name); + await Plain(token, p => p.Tags); await Plain(token, m => m.AvatarId); await Plain(token, m => m.TargetActorURI); await Plain(token, r => r.IsResolved, r => r.ID); diff --git a/PrivaPub/Models/Social/FollowedTag.cs b/PrivaPub/Models/Social/FollowedTag.cs new file mode 100644 index 0000000..c3b08e8 --- /dev/null +++ b/PrivaPub/Models/Social/FollowedTag.cs @@ -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; + } +} diff --git a/PrivaPub/Services/RootRemoval.cs b/PrivaPub/Services/RootRemoval.cs index 73737b5..ac6f876 100644 --- a/PrivaPub/Services/RootRemoval.cs +++ b/PrivaPub/Services/RootRemoval.cs @@ -65,6 +65,7 @@ namespace PrivaPub.Services await DB.Default.DeleteAsync(m => m.AvatarId == avatar.ID); await DB.Default.DeleteAsync(l => l.AvatarId == avatar.ID); await DB.Default.DeleteAsync(f => f.AvatarId == avatar.ID); + await DB.Default.DeleteAsync(t => t.AvatarId == avatar.ID); } await DB.Default.Update().MatchID(root.ID) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4e2b1d3..58339c3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,6 +71,9 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati - [x] Scheduled posts: `scheduled_at` (at least five minutes ahead; 300 waiting, 25 a day) keeps the post as asked, with its media kept from the janitor, and a `PublishScheduled` job publishes it at its time; `scheduled_statuses` lists, moves and drops them. + - [x] Followed hashtags: `tags/:name` with its week of public use, follow and unfollow, `followed_tags`; a public post + (not a boost or a reply) PrivaPub holds comes to the homes of those following one of its tags. Nothing is fetched + for a followed tag, and no other server hears of it. ## Intent @@ -457,7 +460,8 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol - **Lists:** CRUD, `:id/accounts`, `accounts/:id/lists`, `timelines/list/:id` (P9). - **Filters:** v2 with keywords and statuses, v1, `filtered` on statuses (P9). - **Scheduled statuses:** `scheduled_at` on posting, `scheduled_statuses` (P9). - - **Stubs:** `custom_emojis`, announcements, trends, suggestions, `followed_tags`, preferences. + - **Tags:** `tags/:name` with history, follow and unfollow, `followed_tags` (P9). + - **Stubs:** `custom_emojis`, announcements, trends, suggestions, preferences. - **Mapping:** - Avatar, ForeignAvatar and Group all map to Account (`group: true` for groups). `acct` uses a WebFinger-verified handle; `created_at` is truncated to the day; avatar and header always have a placeholder URL.