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
@@ -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)
|
||||
|
||||
@@ -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, $"<p>far away #{tag}</p>", 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+5
-1
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user