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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
4b6d261739
commit
0614bdcf18
13 files changed
+406
-21
No files matched your search
@@ -619,7 +619,6 @@ namespace PrivaPub.Tests.Http
|
|||||||
var alice = await _host.Mastodon("alice");
|
var alice = await _host.Mastodon("alice");
|
||||||
|
|
||||||
Assert.Empty((await _host.Client().Get($"/api/v1/accounts/{alice.Id}/featured_tags")).Ok().Array);
|
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();
|
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.Equal(alice.Id, Assert.Single(familiar.Array).Text("id"));
|
||||||
Assert.Empty(familiar.Array[0]!["accounts"]!.AsArray());
|
Assert.Empty(familiar.Array[0]!["accounts"]!.AsArray());
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ namespace PrivaPub.Tests.Http
|
|||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/api/v1/filters")]
|
[InlineData("/api/v1/filters")]
|
||||||
[InlineData("/api/v2/filters")]
|
[InlineData("/api/v2/filters")]
|
||||||
[InlineData("/api/v1/lists")]
|
|
||||||
[InlineData("/api/v1/suggestions")]
|
[InlineData("/api/v1/suggestions")]
|
||||||
[InlineData("/api/v2/suggestions")]
|
[InlineData("/api/v2/suggestions")]
|
||||||
[InlineData("/api/v1/followed_tags")]
|
[InlineData("/api/v1/followed_tags")]
|
||||||
|
|||||||
@@ -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<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
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]
|
[Fact]
|
||||||
public async Task Favourites_list_what_the_persona_liked_newest_first()
|
public async Task Favourites_list_what_the_persona_liked_newest_first()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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]
|
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
public IActionResult FeaturedTags(string id) => Json(Array.Empty<object>());
|
public IActionResult FeaturedTags(string id) => Json(Array.Empty<object>());
|
||||||
|
|
||||||
[HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")]
|
|
||||||
public IActionResult Lists(string id) => Json(Array.Empty<object>());
|
|
||||||
|
|
||||||
[HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")]
|
[HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")]
|
||||||
public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty<Account>() }).ToList());
|
public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty<Account>() }).ToList());
|
||||||
|
|
||||||
|
|||||||
@@ -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<PersonaList> Mine(string id, CancellationToken token) =>
|
||||||
|
DB.Default.Find<PersonaList>().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<List<string>> Members(PersonaList list, CancellationToken token)
|
||||||
|
{
|
||||||
|
var members = (await DB.Default.Find<PersonaListMember>().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<PersonaListMember>(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<IActionResult> All(CancellationToken token) =>
|
||||||
|
Json((await DB.Default.Find<PersonaList>().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<IActionResult> 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<IActionResult> 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<PersonaList>(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<IActionResult> 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<IActionResult> Delete(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (await Mine(id, token) is not { } list)
|
||||||
|
return NotFoundError();
|
||||||
|
await DB.Default.DeleteAsync<PersonaListMember>(m => m.ListId == list.ID);
|
||||||
|
await DB.Default.DeleteAsync<PersonaList>(list.ID);
|
||||||
|
return Json(new { });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("/api/v1/lists/{id}/accounts"), Scope("read:lists")]
|
||||||
|
public async Task<IActionResult> 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<PersonaListMember>().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<IActionResult> 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<PersonaListMember>().Match(m => m.ListId == list.ID && ids.Contains(m.AccountId)).ExecuteAsync(token))
|
||||||
|
.Select(m => m.AccountId).ToHashSet();
|
||||||
|
if (await DB.Default.CountAsync<PersonaListMember>(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<IActionResult> 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<PersonaListMember>(m => m.ListId == list.ID && ids.Contains(m.AccountId));
|
||||||
|
return Json(new { });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")]
|
||||||
|
public async Task<IActionResult> Containing(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
var listIds = (await DB.Default.Find<PersonaListMember>().Match(m => m.AvatarId == MyId && m.AccountId == id).ExecuteAsync(token))
|
||||||
|
.Select(m => m.ListId).ToList();
|
||||||
|
return Json((await DB.Default.Find<PersonaList>().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<IActionResult> 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<string> { 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,9 +90,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
[HttpGet("/api/v2/filters"), Scope("read:filters")]
|
[HttpGet("/api/v2/filters"), Scope("read:filters")]
|
||||||
public IActionResult FiltersV2() => Json(Array.Empty<object>());
|
public IActionResult FiltersV2() => Json(Array.Empty<object>());
|
||||||
|
|
||||||
[HttpGet("/api/v1/lists"), Scope("read:lists")]
|
|
||||||
public IActionResult Lists() => Json(Array.Empty<object>());
|
|
||||||
|
|
||||||
[HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
[HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
public IActionResult Announcements() => Json(Array.Empty<object>());
|
public IActionResult Announcements() => Json(Array.Empty<object>());
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
|
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
|
||||||
public async Task<IActionResult> Home(CancellationToken token)
|
public async Task<IActionResult> 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<PersonaList>().Match(l => l.AvatarId == MyId && l.Exclusive).ExecuteAsync(token)).Select(l => l.ID).ToList();
|
||||||
|
var apart = exclusive.Count == 0
|
||||||
|
? new List<string>()
|
||||||
|
: (await DB.Default.Find<PersonaListMember>().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 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 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);
|
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);
|
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<Status>());
|
|
||||||
|
|
||||||
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
|
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
|
||||||
public async Task<IActionResult> Favourites(CancellationToken token)
|
public async Task<IActionResult> Favourites(CancellationToken token)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ namespace PrivaPub.Domain.Social
|
|||||||
{
|
{
|
||||||
return await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token);
|
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<PersonaListMember>(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId);
|
||||||
|
|
||||||
if (local != default)
|
if (local != default)
|
||||||
await FollowLocally(follower, local, following, token);
|
await FollowLocally(follower, local, following, token);
|
||||||
@@ -145,6 +147,7 @@ namespace PrivaPub.Domain.Social
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
await DB.Default.DeleteAsync<Following>(following.ID);
|
await DB.Default.DeleteAsync<Following>(following.ID);
|
||||||
|
await DB.Default.DeleteAsync<PersonaListMember>(m => m.AvatarId == follower.Id && m.AccountId == following.TargetAccountId);
|
||||||
if (following.TargetIsLocal)
|
if (following.TargetIsLocal)
|
||||||
{
|
{
|
||||||
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri);
|
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri);
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
foreach (var pair in new (Func<Task>, string)[]
|
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<Block>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "block"),
|
||||||
|
(() => 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<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"),
|
(() => DB.Default.Index<Mute>().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"),
|
||||||
(() => DB.Default.Index<AccountDomainBlock>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"),
|
(() => DB.Default.Index<AccountDomainBlock>().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 pair.Item1();
|
||||||
await Plain<Block>(token, b => b.TargetActorURI);
|
await Plain<Block>(token, b => b.TargetActorURI);
|
||||||
await Plain<BlockedBy>(token, b => b.ActorURI);
|
await Plain<BlockedBy>(token, b => b.ActorURI);
|
||||||
|
await Plain<PersonaList>(token, l => l.AvatarId);
|
||||||
|
await Plain<PersonaListMember>(token, m => m.AvatarId);
|
||||||
await Plain<Mute>(token, m => m.TargetActorURI);
|
await Plain<Mute>(token, m => m.TargetActorURI);
|
||||||
await Plain<Report>(token, r => r.IsResolved, r => r.ID);
|
await Plain<Report>(token, r => r.IsResolved, r => r.ID);
|
||||||
await Unique<DomainBlock>(b => b.Domain, Builders<DomainBlock>.Filter.Type(b => b.Domain, BsonType.String), token);
|
await Unique<DomainBlock>(b => b.Domain, Builders<DomainBlock>.Filter.Type(b => b.Domain, BsonType.String), token);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,8 @@ namespace PrivaPub.Services
|
|||||||
await Announce(persona, followed, token);
|
await Announce(persona, followed, token);
|
||||||
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token);
|
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token);
|
||||||
await Empty(avatar.ID, now, token);
|
await Empty(avatar.ID, now, token);
|
||||||
|
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.Update<RootUser>().MatchID(root.ID)
|
await DB.Default.Update<RootUser>().MatchID(root.ID)
|
||||||
|
|||||||
+4
-1
@@ -63,6 +63,8 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
|||||||
- [ ] P8 Signatures, discovery, the long tail
|
- [ ] P8 Signatures, discovery, the long tail
|
||||||
- [ ] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts,
|
- [ ] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts,
|
||||||
followed hashtags, trends and a directory
|
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
|
## Intent
|
||||||
|
|
||||||
@@ -446,7 +448,8 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol
|
|||||||
Announce), `reblogged_by`, `favourited_by`.
|
Announce), `reblogged_by`, `favourited_by`.
|
||||||
- **Timelines and the rest:** home, public, tag; notifications; markers; conversations; `/api/v2/search` with
|
- **Timelines and the rest:** home, public, tag; notifications; markers; conversations; `/api/v2/search` with
|
||||||
`resolve`.
|
`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:**
|
- **Mapping:**
|
||||||
- Avatar, ForeignAvatar and Group all map to Account (`group: true` for groups). `acct` uses a WebFinger-verified
|
- 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.
|
handle; `created_at` is truncated to the day; avatar and header always have a placeholder URL.
|
||||||
|
|||||||
Reference in new issue
Block a user