From 0614bdcf18ec2da27d9c8f4863312c3d6864f22e Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 01:54:30 +0200 Subject: [PATCH] Lists: a persona's followed accounts, read apart Mastodon's lists replace the empty stubs: CRUD, members (only accounts the persona follows; a follow that ends takes its memberships with it), accounts/:id/lists, and timelines/list/:id from the persona's home entries with the replies policy (followed, list, none; self-replies and replies to the persona always). An exclusive list's members stay out of home. Lists never federate, and go with a deleted persona. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- PrivaPub.Tests/Http/MastodonAccountsTests.cs | 1 - PrivaPub.Tests/Http/MastodonInstanceTests.cs | 1 - PrivaPub.Tests/Http/MastodonListsTests.cs | 164 +++++++++++++++ PrivaPub.Tests/Http/MastodonTimelinesTests.cs | 8 - .../Controllers/AccountsController.cs | 3 - .../Mastodon/Controllers/ListsController.cs | 189 ++++++++++++++++++ .../Mastodon/Controllers/SearchController.cs | 3 - .../Controllers/TimelinesController.cs | 13 +- PrivaPub/Domain/Social/FollowService.cs | 3 + PrivaPub/Infrastructure/Data/Indexes.cs | 3 + PrivaPub/Models/Social/Lists.cs | 32 +++ PrivaPub/Services/RootRemoval.cs | 2 + docs/ROADMAP.md | 5 +- 13 files changed, 406 insertions(+), 21 deletions(-) create mode 100644 PrivaPub.Tests/Http/MastodonListsTests.cs create mode 100644 PrivaPub/Api/Mastodon/Controllers/ListsController.cs create mode 100644 PrivaPub/Models/Social/Lists.cs diff --git a/PrivaPub.Tests/Http/MastodonAccountsTests.cs b/PrivaPub.Tests/Http/MastodonAccountsTests.cs index cdad603..73c2a1c 100644 --- a/PrivaPub.Tests/Http/MastodonAccountsTests.cs +++ b/PrivaPub.Tests/Http/MastodonAccountsTests.cs @@ -619,7 +619,6 @@ namespace PrivaPub.Tests.Http var alice = await _host.Mastodon("alice"); Assert.Empty((await _host.Client().Get($"/api/v1/accounts/{alice.Id}/featured_tags")).Ok().Array); - Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/lists")).Ok().Array); var familiar = (await alice.Client.Get($"/api/v1/accounts/familiar_followers?id[]={alice.Id}")).Ok(); Assert.Equal(alice.Id, Assert.Single(familiar.Array).Text("id")); Assert.Empty(familiar.Array[0]!["accounts"]!.AsArray()); diff --git a/PrivaPub.Tests/Http/MastodonInstanceTests.cs b/PrivaPub.Tests/Http/MastodonInstanceTests.cs index 059df74..554ec70 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/filters")] [InlineData("/api/v2/filters")] - [InlineData("/api/v1/lists")] [InlineData("/api/v1/suggestions")] [InlineData("/api/v2/suggestions")] [InlineData("/api/v1/followed_tags")] diff --git a/PrivaPub.Tests/Http/MastodonListsTests.cs b/PrivaPub.Tests/Http/MastodonListsTests.cs new file mode 100644 index 0000000..00f133d --- /dev/null +++ b/PrivaPub.Tests/Http/MastodonListsTests.cs @@ -0,0 +1,164 @@ +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; + +namespace PrivaPub.Tests.Http +{ + // Mastodon's lists: a persona's own groups of accounts it follows, read as timelines, never federated + [Trait("Category", "Integration")] + public sealed class MastodonListsTests : IAsyncLifetime + { + PrivaPubHost _host; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + static async Task Follow(Mastodon follower, Mastodon target) => + (await follower.Client.Post($"/api/v1/accounts/{target.Id}/follow")).Ok(); + + static async Task List(Mastodon owner, string title, params (string Key, string Value)[] more) => + (await owner.Client.Post("/api/v1/lists", more.Prepend(("title", title)).ToArray())).Ok().Object.Text("id"); + + [Fact] + public async Task A_list_is_created_renamed_and_deleted_by_its_owner_only() + { + var alice = await _host.Mastodon("alice"); + var mallory = await _host.Mastodon("mallory"); + + var created = (await alice.Client.Post("/api/v1/lists", ("title", "Friends"), ("replies_policy", "followed"), ("exclusive", "true"))).Ok().Object; + var id = created.Text("id"); + Assert.Equal("Friends", created.Text("title")); + Assert.Equal("followed", created.Text("replies_policy")); + Assert.True(created.Flag("exclusive")); + var other = await List(alice, "Art"); + + Assert.Equal(new[] { other, id }, (await alice.Client.Get("/api/v1/lists")).Ok().Ids); + var renamed = (await alice.Client.Put($"/api/v1/lists/{id}", ("title", "Close friends"), ("exclusive", "false"))).Ok().Object; + Assert.Equal("Close friends", renamed.Text("title")); + Assert.Equal("followed", renamed.Text("replies_policy")); + Assert.False(renamed.Flag("exclusive")); + Assert.Equal("Close friends", (await alice.Client.Get($"/api/v1/lists/{id}")).Ok().Object.Text("title")); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/lists", ("title", " "))).Status); + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/lists", ("title", "x"), ("replies_policy", "everyone"))).Status); + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put($"/api/v1/lists/{id}", ("title", ""))).Status); + Assert.Empty((await mallory.Client.Get("/api/v1/lists")).Ok().Array); + Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/lists/{id}")).Status); + Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Put($"/api/v1/lists/{id}", ("title", "mine"))).Status); + Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v1/lists/{id}")).Status); + Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/timelines/list/{id}")).Status); + Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/lists")).Status); + + (await alice.Client.Delete($"/api/v1/lists/{id}")).Ok(); + Assert.Equal(new[] { other }, (await alice.Client.Get("/api/v1/lists")).Ok().Ids); + Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/lists/{id}")).Status); + } + + [Fact] + public async Task Only_followed_accounts_join_a_list_and_its_timeline_shows_their_posts() + { + var alice = await _host.Mastodon("alice"); + var bob = await _host.Mastodon("bob"); + var carol = await _host.Mastodon("carol"); + var dave = await _host.Mastodon("dave"); + await Follow(alice, bob); + await Follow(alice, dave); + var list = await List(alice, "Bob"); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", carol.Id))).Status); + (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); + (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); + Assert.Equal(new[] { bob.Id }, (await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Ids); + Assert.Equal(new[] { list }, (await alice.Client.Get($"/api/v1/accounts/{bob.Id}/lists")).Ok().Ids); + Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{dave.Id}/lists")).Ok().Array); + + var fromBob = (await bob.Status("from bob")).Text("id"); + await dave.Status("from dave"); + await carol.Status("from carol"); + Assert.Equal(new[] { fromBob }, (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids); + + (await alice.Client.Delete($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); + Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array); + Assert.Empty((await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Array); + } + + [Theory] + [InlineData("list", true, false, false)] + [InlineData("followed", true, true, false)] + [InlineData("none", true, false, false)] + public async Task A_lists_replies_follow_its_policy(string policy, bool toSelf, bool toFollowed, bool toStranger) + { + var alice = await _host.Mastodon("alice"); + var bob = await _host.Mastodon("bob"); + var dave = await _host.Mastodon("dave"); + var carol = await _host.Mastodon("carol"); + await Follow(alice, bob); + await Follow(alice, dave); + var list = await List(alice, "Bob", ("replies_policy", policy)); + (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); + var root = (await bob.Status("a thread")).Text("id"); + var mine = (await alice.Status("asking bob")).Text("id"); + var ofDave = (await dave.Status("dave's")).Text("id"); + var ofCarol = (await carol.Status("carol's")).Text("id"); + + var self = (await bob.Status("and more", ("in_reply_to_id", root))).Text("id"); + var toAlice = (await bob.Status($"@{alice.UserName} answered", ("in_reply_to_id", mine))).Text("id"); + var toDave = (await bob.Status($"@{dave.UserName} agreed", ("in_reply_to_id", ofDave))).Text("id"); + var toCarol = (await bob.Status($"@{carol.UserName} hm", ("in_reply_to_id", ofCarol))).Text("id"); + + var shown = (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids.ToList(); + Assert.Contains(root, shown); + Assert.Equal(toSelf, shown.Contains(self)); + Assert.Contains(toAlice, shown); + Assert.Equal(toFollowed, shown.Contains(toDave)); + Assert.Equal(toStranger, shown.Contains(toCarol)); + } + + [Fact] + public async Task An_exclusive_lists_members_are_read_there_and_not_at_home() + { + var alice = await _host.Mastodon("alice"); + var bob = await _host.Mastodon("bob"); + var dave = await _host.Mastodon("dave"); + await Follow(alice, bob); + await Follow(alice, dave); + var list = await List(alice, "Apart", ("exclusive", "true")); + (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); + var fromBob = (await bob.Status("from bob")).Text("id"); + var fromDave = (await dave.Status("from dave")).Text("id"); + + var home = (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids.ToList(); + Assert.Contains(fromDave, home); + Assert.DoesNotContain(fromBob, home); + Assert.Equal(new[] { fromBob }, (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids); + + (await alice.Client.Put($"/api/v1/lists/{list}", ("exclusive", "false"))).Ok(); + Assert.Contains(fromBob, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids); + } + + [Fact] + public async Task An_unfollowed_account_leaves_the_personas_lists_and_does_not_come_back_with_a_new_follow() + { + var alice = await _host.Mastodon("alice"); + var bob = await _host.Mastodon("bob"); + await Follow(alice, bob); + var list = await List(alice, "Bob", ("exclusive", "true")); + (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); + + (await alice.Client.Post($"/api/v1/accounts/{bob.Id}/unfollow")).Ok(); + Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array); + Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{bob.Id}/lists")).Ok().Array); + + await Follow(alice, bob); + var fromBob = (await bob.Status("back again")).Text("id"); + Assert.Contains(fromBob, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids); + Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array); + } + } +} diff --git a/PrivaPub.Tests/Http/MastodonTimelinesTests.cs b/PrivaPub.Tests/Http/MastodonTimelinesTests.cs index 6bd5d73..abeb98a 100644 --- a/PrivaPub.Tests/Http/MastodonTimelinesTests.cs +++ b/PrivaPub.Tests/Http/MastodonTimelinesTests.cs @@ -105,14 +105,6 @@ namespace PrivaPub.Tests.Http Assert.Equal(new[] { later, open }, (await alice.Client.Get($"/api/v1/timelines/tag/%23{tag}")).Ok().Ids); } - [Fact] - public async Task A_list_timeline_is_an_empty_stub() - { - var alice = await _host.Mastodon("alice"); - - Assert.Empty((await alice.Client.Get("/api/v1/timelines/list/anything")).Ok().Array); - } - [Fact] public async Task Favourites_list_what_the_persona_liked_newest_first() { diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index 62af29e..420c4a9 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -342,9 +342,6 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult FeaturedTags(string id) => Json(Array.Empty()); - [HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")] - public IActionResult Lists(string id) => Json(Array.Empty()); - [HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")] public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty() }).ToList()); diff --git a/PrivaPub/Api/Mastodon/Controllers/ListsController.cs b/PrivaPub/Api/Mastodon/Controllers/ListsController.cs new file mode 100644 index 0000000..30868fe --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/ListsController.cs @@ -0,0 +1,189 @@ +using Microsoft.AspNetCore.Mvc; + +using MongoDB.Entities; + +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Api.Mastodon.Mappers; +using PrivaPub.Domain.Privacy; +using PrivaPub.Models.Social; +using PrivaPub.StaticServices; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + // Mastodon's lists (owner decision 2026-10-04, back from the cut list): a persona groups accounts it follows, reads them + // as a timeline, and may keep an exclusive list's members out of its home. Nothing here leaves the server. + public class ListsController : MastodonController + { + const int MaxLists = 50; + const int MaxMembers = 500; + + readonly DbEntities _dbEntities; + readonly MastodonMapper _mapper; + + public ListsController(DbEntities dbEntities, MastodonMapper mapper) + { + _dbEntities = dbEntities; + _mapper = mapper; + } + + static object View(PersonaList list) => new { id = list.ID, title = list.Title, replies_policy = list.RepliesPolicy, exclusive = list.Exclusive }; + + Task Mine(string id, CancellationToken token) => + DB.Default.Find().Match(l => l.ID == id && l.AvatarId == MyId).ExecuteFirstAsync(token); + + // a list holds only accounts the persona still follows: whatever ended the follow (an unfollow, a Reject, a Block, the + // account's deletion), its membership goes with it, pruned here on the next read + async Task> Members(PersonaList list, CancellationToken token) + { + var members = (await DB.Default.Find().Match(m => m.ListId == list.ID).ExecuteAsync(token)).Select(m => m.AccountId).ToList(); + if (members.Count == 0) + return members; + var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && members.Contains(f.TargetAccountId)) + .ExecuteAsync(token)).Select(f => f.TargetAccountId).ToHashSet(); + var gone = members.Where(m => !followed.Contains(m)).ToList(); + if (gone.Count > 0) + await DB.Default.DeleteAsync(m => m.ListId == list.ID && gone.Contains(m.AccountId)); + return members.Where(followed.Contains).ToList(); + } + + [HttpGet("/api/v1/lists"), Scope("read:lists")] + public async Task All(CancellationToken token) => + Json((await DB.Default.Find().Match(l => l.AvatarId == MyId).Sort(l => l.Title, MongoDB.Entities.Order.Ascending) + .ExecuteAsync(token)).Select(View).ToList()); + + [HttpGet("/api/v1/lists/{id}"), Scope("read:lists")] + public async Task One(string id, CancellationToken token) => + await Mine(id, token) is { } list ? Json(View(list)) : NotFoundError(); + + [HttpPost("/api/v1/lists"), Scope("write:lists")] + public async Task Create(CancellationToken token) + { + var title = Params.Get("title")?.Trim(); + var policy = Params.Get("replies_policy") ?? ListRepliesPolicy.List; + if (string.IsNullOrEmpty(title) || title.Length > 200) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Title can't be blank"); + if (!ListRepliesPolicy.IsValid(policy)) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Replies policy is not included in the list"); + if (await DB.Default.CountAsync(l => l.AvatarId == MyId, token) >= MaxLists) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many lists"); + var list = new PersonaList { AvatarId = MyId, Title = title, RepliesPolicy = policy, Exclusive = Params.Bool("exclusive") == true }; + await DB.Default.SaveAsync(list, token); + return Json(View(list)); + } + + [HttpPut("/api/v1/lists/{id}"), Scope("write:lists")] + public async Task Update(string id, CancellationToken token) + { + if (await Mine(id, token) is not { } list) + return NotFoundError(); + var title = Params.Get("title")?.Trim(); + var policy = Params.Get("replies_policy"); + if (title != default) + { + if (title.Length == 0 || title.Length > 200) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Title can't be blank"); + list.Title = title; + } + if (policy != default) + { + if (!ListRepliesPolicy.IsValid(policy)) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Replies policy is not included in the list"); + list.RepliesPolicy = policy; + } + if (Params.Bool("exclusive") is { } exclusive) + list.Exclusive = exclusive; + await DB.Default.SaveAsync(list, token); + return Json(View(list)); + } + + [HttpDelete("/api/v1/lists/{id}"), Scope("write:lists")] + public async Task Delete(string id, CancellationToken token) + { + if (await Mine(id, token) is not { } list) + return NotFoundError(); + await DB.Default.DeleteAsync(m => m.ListId == list.ID); + await DB.Default.DeleteAsync(list.ID); + return Json(new { }); + } + + [HttpGet("/api/v1/lists/{id}/accounts"), Scope("read:lists")] + public async Task Accounts(string id, CancellationToken token) + { + if (await Mine(id, token) is not { } list) + return NotFoundError(); + await Members(list, token); + var members = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find().Match(m => m.ListId == list.ID), m => m.ID, token); + var accounts = await _mapper.Accounts(members.Select(m => m.AccountId), token); + Link($"/api/v1/lists/{id}/accounts", members.LastOrDefault()?.ID, members.FirstOrDefault()?.ID); + return Json(members.Where(m => accounts.ContainsKey(m.AccountId)).Select(m => accounts[m.AccountId]).ToList()); + } + + // only accounts the persona follows may join its list, as on Mastodon + [HttpPost("/api/v1/lists/{id}/accounts"), Scope("write:lists")] + public async Task Add(string id, CancellationToken token) + { + if (await Mine(id, token) is not { } list) + return NotFoundError(); + var ids = Params.List("account_ids").Distinct().ToList(); + if (ids.Count == 0) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Account ids can't be blank"); + var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && ids.Contains(f.TargetAccountId)) + .ExecuteAsync(token)).Select(f => f.TargetAccountId).ToHashSet(); + if (ids.Any(a => !followed.Contains(a))) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: You must follow this account to add it to a list"); + var present = (await DB.Default.Find().Match(m => m.ListId == list.ID && ids.Contains(m.AccountId)).ExecuteAsync(token)) + .Select(m => m.AccountId).ToHashSet(); + if (await DB.Default.CountAsync(m => m.ListId == list.ID, token) + ids.Count(a => !present.Contains(a)) > MaxMembers) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many accounts in the list"); + foreach (var account in ids.Where(a => !present.Contains(a))) + await DB.Default.SaveAsync(new PersonaListMember { ListId = list.ID, AvatarId = MyId, AccountId = account }, token); + return Json(new { }); + } + + [HttpDelete("/api/v1/lists/{id}/accounts"), Scope("write:lists")] + public async Task Remove(string id, CancellationToken token) + { + if (await Mine(id, token) is not { } list) + return NotFoundError(); + var ids = Params.List("account_ids"); + await DB.Default.DeleteAsync(m => m.ListId == list.ID && ids.Contains(m.AccountId)); + return Json(new { }); + } + + [HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")] + public async Task Containing(string id, CancellationToken token) + { + var listIds = (await DB.Default.Find().Match(m => m.AvatarId == MyId && m.AccountId == id).ExecuteAsync(token)) + .Select(m => m.ListId).ToList(); + return Json((await DB.Default.Find().Match(l => l.AvatarId == MyId && listIds.Contains(l.ID)).ExecuteAsync(token)) + .Select(View).ToList()); + } + + // the list's timeline: the persona's home entries by its members. Replies are kept as Mastodon keeps them: a member's + // replies to itself and to the persona always, to others as the policy says (anyone followed, the list's members, no one) + [HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")] + public async Task Timeline(string id, CancellationToken token) + { + if (await Mine(id, token) is not { } list) + return NotFoundError(); + var members = await Members(list, token); + var entries = await Page.From(Params, Limit()).Fetch( + _dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId && members.Contains(e.AuthorAccountId)), e => e.PostId, token); + var ids = entries.Select(e => e.PostId).ToList(); + var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID); + var answered = list.RepliesPolicy switch + { + ListRepliesPolicy.Followed => (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted).ExecuteAsync(token)) + .Select(f => f.TargetAccountId).Append(MyId).ToHashSet(), + ListRepliesPolicy.List => members.Append(MyId).ToHashSet(), + _ => new HashSet { MyId } + }; + var shown = ids.Where(posts.ContainsKey).Select(i => posts[i]) + .Where(p => p.ReblogOfPostId != default || string.IsNullOrEmpty(p.AnsweringToPostId) && string.IsNullOrEmpty(p.InReplyToURI) + || p.InReplyToAccountId != default && (p.InReplyToAccountId == p.AuthorAccountId || answered.Contains(p.InReplyToAccountId))) + .ToList(); + Link($"/api/v1/timelines/list/{id}", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId); + return Json(await _mapper.Statuses(shown, MyId, token)); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index 708b0d0..211db73 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -90,9 +90,6 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v2/filters"), Scope("read:filters")] public IActionResult FiltersV2() => Json(Array.Empty()); - [HttpGet("/api/v1/lists"), Scope("read:lists")] - public IActionResult Lists() => Json(Array.Empty()); - [HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult Announcements() => Json(Array.Empty()); diff --git a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs index 0d846e6..5c237b1 100644 --- a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs @@ -30,7 +30,15 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/timelines/home"), Scope("read:statuses")] public async Task Home(CancellationToken token) { - var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token); + //an exclusive list's members are read in that list, not at home + var exclusive = (await DB.Default.Find().Match(l => l.AvatarId == MyId && l.Exclusive).ExecuteAsync(token)).Select(l => l.ID).ToList(); + var apart = exclusive.Count == 0 + ? new List() + : (await DB.Default.Find().Match(m => exclusive.Contains(m.ListId)).ExecuteAsync(token)).Select(m => m.AccountId).Distinct().ToList(); + var home = _dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId); + if (apart.Count > 0) + home.Match(e => !apart.Contains(e.AuthorAccountId)); + var entries = await Page.From(Params, Limit()).Fetch(home, e => e.PostId, token); var ids = entries.Select(e => e.PostId).ToList(); var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID); var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token); @@ -61,9 +69,6 @@ namespace PrivaPub.Api.Mastodon.Controllers return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token); } - [HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")] - public IActionResult List(string id) => Json(Array.Empty()); - [HttpGet("/api/v1/favourites"), Scope("read:favourites")] public async Task Favourites(CancellationToken token) { diff --git a/PrivaPub/Domain/Social/FollowService.cs b/PrivaPub/Domain/Social/FollowService.cs index b8a6dc6..1ffc28b 100644 --- a/PrivaPub/Domain/Social/FollowService.cs +++ b/PrivaPub/Domain/Social/FollowService.cs @@ -128,6 +128,8 @@ namespace PrivaPub.Domain.Social { return await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token); } + //a new follow starts in no list, whatever lists the account was in before a follow that ended without passing here + await DB.Default.DeleteAsync(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId); if (local != default) await FollowLocally(follower, local, following, token); @@ -145,6 +147,7 @@ namespace PrivaPub.Domain.Social return; await DB.Default.DeleteAsync(following.ID); + await DB.Default.DeleteAsync(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId); if (following.TargetIsLocal) { await DB.Default.DeleteAsync(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri); diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 70d67f1..2b8c9d3 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(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"), (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"), @@ -104,6 +105,8 @@ namespace PrivaPub.Infrastructure.Data await pair.Item1(); await Plain(token, b => b.TargetActorURI); await Plain(token, b => b.ActorURI); + await Plain(token, l => l.AvatarId); + await Plain(token, m => m.AvatarId); await Plain(token, m => m.TargetActorURI); await Plain(token, r => r.IsResolved, r => r.ID); await Unique(b => b.Domain, Builders.Filter.Type(b => b.Domain, BsonType.String), token); diff --git a/PrivaPub/Models/Social/Lists.cs b/PrivaPub/Models/Social/Lists.cs new file mode 100644 index 0000000..6267eb1 --- /dev/null +++ b/PrivaPub/Models/Social/Lists.cs @@ -0,0 +1,32 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + //a persona's list of accounts it follows (Mastodon lists): a timeline of their posts, and, when exclusive, their posts + //kept out of the home timeline. Lists are the persona's own and never federate. + public class PersonaList : Entity + { + public string AvatarId { get; set; } + public string Title { get; set; } + public string RepliesPolicy { get; set; } = ListRepliesPolicy.List; + public bool Exclusive { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public class PersonaListMember : Entity + { + public string ListId { get; set; } + public string AvatarId { get; set; }//the list's owner + public string AccountId { get; set; }//the member: a ForeignAvatar.ID, an Avatar.ID or a community's Group.ID + public DateTime AddedAt { get; set; } = DateTime.UtcNow; + } + + public static class ListRepliesPolicy + { + public const string Followed = "followed";//replies to anyone the persona follows + public const string List = "list";//replies to members of the list + public const string None = "none"; + + public static bool IsValid(string value) => value is Followed or List or None; + } +} diff --git a/PrivaPub/Services/RootRemoval.cs b/PrivaPub/Services/RootRemoval.cs index dfee7ee..d72c725 100644 --- a/PrivaPub/Services/RootRemoval.cs +++ b/PrivaPub/Services/RootRemoval.cs @@ -62,6 +62,8 @@ namespace PrivaPub.Services await Announce(persona, followed, token); await DB.Default.Update().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token); await Empty(avatar.ID, now, token); + await DB.Default.DeleteAsync(m => m.AvatarId == avatar.ID); + await DB.Default.DeleteAsync(l => l.AvatarId == avatar.ID); } await DB.Default.Update().MatchID(root.ID) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ca7013a..cbbded1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -63,6 +63,8 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati - [ ] P8 Signatures, discovery, the long tail - [ ] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts, followed hashtags, trends and a directory + - [x] Lists: CRUD, members (followed accounts only, dropped when the follow ends), `timelines/list` with Mastodon's + replies policies, exclusive lists kept out of home. Lists are the persona's own and never federate. ## Intent @@ -446,7 +448,8 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol Announce), `reblogged_by`, `favourited_by`. - **Timelines and the rest:** home, public, tag; notifications; markers; conversations; `/api/v2/search` with `resolve`. - - **Stubs:** `custom_emojis`, filters, lists, announcements, trends, suggestions, `followed_tags`, preferences. + - **Lists:** CRUD, `:id/accounts`, `accounts/:id/lists`, `timelines/list/:id` (P9). + - **Stubs:** `custom_emojis`, filters, announcements, trends, suggestions, `followed_tags`, 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.