Files
SocialPub/PrivaPub/Api/Mastodon/Controllers/ListsController.cs
T
thepraandClaude Opus 5.5 0614bdcf18 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
2026-10-05 01:54:30 +02:00

190 lines
9.6 KiB
C#

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));
}
}
}