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
@@ -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<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")]
|
||||
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")]
|
||||
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]
|
||||
public IActionResult Announcements() => Json(Array.Empty<object>());
|
||||
|
||||
|
||||
@@ -30,7 +30,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
|
||||
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 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<Status>());
|
||||
|
||||
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
|
||||
public async Task<IActionResult> Favourites(CancellationToken token)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user