R8 the Mastodon API made real: featured tags, endorsements, notes, notify and languages, suggestions, the direct stream

Featured tags per persona (/api/v1/featured_tags, suggestions, tags/:name/feature, accounts' featured tags, max 10),
listed at /tattoos as Hashtags and sent as Add/Remove{Hashtag} to followers on the featured collection, as Mastodon
sends them; remote accounts' tags received and read from featuredTags. Endorsements (pin/endorse) and private notes
(PersonaNote) kept per persona and never federated; the relationship's notifying, languages, endorsed and note, with
follow's notify (status notifications) and languages (home filter). Reading preferences set on /clientapi. Suggestions
v1/v2 with dismissals and staff picks, never the persona or a sibling. conversation events on the direct stream.
translate, annual reports and the admin API answer. New collections backed up and removed with their persona.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 08:38:11 +02:00
1 parent d08fbabaa5
commit 87529bca49
42 files changed
+1505 -77

No files matched your search

@@ -304,7 +304,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
await _feeds.Subscribe(Me, remote, true, token);
return Json(await Relationship(id, token));
}
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
// notify: a `status` notification for each of its new posts; languages: only its posts in those reach the home (R8).
// Either changes only when given, as on Mastodon
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token, Params.Bool("notify"),
Params.Has("languages") ? Params.List("languages") : default);
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
}
@@ -425,12 +428,112 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> Reject(string id, CancellationToken token) =>
await _follows.Decide(Me, id, accept: false, token) ? Json(await Relationship(id, token)) : NotFoundError();
[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>());
// Familiar followers stay empty by design: which of the accounts the persona follows also follow another account would
// tell who follows whom among the personas here (and their followers lists are never shown, hide_collections), so
// every account answers none
[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());
// Endorsements (R8): Mastodon's account pins under their old names (pin, unpin) and 4.4's (endorse, unendorse). Only an
// account the persona follows may be endorsed, as Mastodon requires; they are the persona's alone, never federated as
// featured accounts and never listed to anyone else (the owner's default: endorsements stay private)
[HttpPost("/api/v1/accounts/{id}/pin"), HttpPost("/api/v1/accounts/{id}/endorse"), Scope("write:accounts")]
public async Task<IActionResult> Endorse(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default || local?.Id == MyId || local is { Suspended: true } || remote is { SuspendedAt: not null })
return NotFoundError();
var uri = local?.Uri ?? remote.ActorURI;
if (!await Follows(uri, token))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: You must be already following the person you want to endorse");
if (!await DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && e.TargetActorURI == uri).ExecuteAnyAsync(token))
{
if (await DB.Default.CountAsync<Endorsement>(e => e.AvatarId == MyId, token) >= MaxEndorsements)
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: You can endorse at most {MaxEndorsements} accounts");
try
{
await DB.Default.SaveAsync(new Endorsement { AvatarId = MyId, TargetAccountId = id, TargetActorURI = uri }, token);
}
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
{
}
}
return Json(await Relationship(id, token));
}
[HttpPost("/api/v1/accounts/{id}/unpin"), HttpPost("/api/v1/accounts/{id}/unendorse"), Scope("write:accounts")]
public async Task<IActionResult> Unendorse(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
var uri = local?.Uri ?? remote.ActorURI;
await DB.Default.DeleteAsync<Endorsement>(e => e.AvatarId == MyId && (e.TargetActorURI == uri || e.TargetAccountId == id));
return Json(await Relationship(id, token));
}
// the persona's own endorsements, newest first, those it still follows
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
public Task<IActionResult> Endorsements(CancellationToken token) => EndorsementsOf("/api/v1/endorsements", token);
// Mastodon 4.4: an account's endorsements. Only the persona's own are answered, to the persona itself: endorsements
// stay private, so every other account answers none
[HttpGet("/api/v1/accounts/{id}/endorsements"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> EndorsementsOfAccount(string id, CancellationToken token) =>
MyId != default && id == MyId ? await EndorsementsOf($"/api/v1/accounts/{id}/endorsements", token) : Json(Array.Empty<Account>());
async Task<IActionResult> EndorsementsOf(string path, CancellationToken token)
{
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && f.FollowerKind == null)
.Project(f => f.Include(x => x.TargetActorURI)).ExecuteAsync(token)).Select(f => f.TargetActorURI).ToList();
var page = await Page.From(Params, Limit(40, 80))
.Fetch(DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && followed.Contains(e.TargetActorURI)), e => e.ID, token);
Link(path, page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
var accounts = await _mapper.Accounts(page.Select(e => e.TargetAccountId), token);
return Json(page.Select(e => accounts.GetValueOrDefault(e.TargetAccountId)).Where(a => a != default).ToList());
}
// A private note on an account (R8): the persona's own (PersonaNote, never its login's), shown only to it in the
// relationship's `note`, never federated; an empty comment takes it away
[HttpPost("/api/v1/accounts/{id}/note"), Scope("write:accounts")]
public async Task<IActionResult> Note(string id, CancellationToken token)
{
var (local, remote) = await Find(id, token);
if (local == default && remote == default)
return NotFoundError();
var comment = Params.Get("comment")?.Trim();
if (comment?.Length > PersonaNote.MaxLength)
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: Comment is too long (maximum is {PersonaNote.MaxLength} characters)");
if (string.IsNullOrEmpty(comment))
await DB.Default.DeleteAsync<PersonaNote>(n => n.AvatarId == MyId && n.TargetAccountId == id);
else
await DB.Default.Update<PersonaNote>().Match(n => n.AvatarId == MyId && n.TargetAccountId == id)
.Modify(n => n.Text, comment)
.Modify(n => n.UpdatedAt, DateTime.UtcNow)
.Modify(b => b.SetOnInsert(n => n.CreatedAt, DateTime.UtcNow))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
return Json(await Relationship(id, token));
}
// Mastodon's preferences: posting defaults (update_credentials' source) and reading ones (R8: AvatarSettings, set
// through /clientapi/avatar/private/preferences, since Mastodon's API sets none)
[HttpGet("/api/v1/preferences"), Scope("read:accounts")]
public IActionResult Preferences() => Json(new Dictionary<string, object>
{
["posting:default:visibility"] = Me.Settings.DefaultVisibility ?? "public",
["posting:default:sensitive"] = Me.Settings.DefaultSensitive,
["posting:default:language"] = Me.Settings.DefaultLanguage,
["reading:expand:media"] = ExpandMediaModes.IsKnown(Me.Settings.ExpandMedia) ? Me.Settings.ExpandMedia : ExpandMediaModes.Default,
["reading:expand:spoilers"] = Me.Settings.ExpandSpoilers
});
const int MaxEndorsements = 500;
async Task<bool> Follows(string uri, CancellationToken token) =>
await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri && f.State == FollowState.Accepted && f.FollowerKind == null)
.ExecuteAnyAsync(token);
async Task<List<Account>> AccountsFor(IEnumerable<string> actorUris, CancellationToken token)
{
var accounts = new List<Account>();
@@ -474,6 +577,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
: remote != default && await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == MyId && b.ActorURI == remote.ActorURI).ExecuteAnyAsync(token);
var domainBlocked = remote != default && (await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).ExecuteAsync(token))
.Any(b => Hidden.Blocks(b.Domain, remote.ActorURI, remote.Domain));
// the persona's own endorsement and note (R8), never another persona's: a note made as a sibling stays its own
var endorsed = uri != default && following?.State == FollowState.Accepted
&& await DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && e.TargetActorURI == uri).ExecuteAnyAsync(token);
var note = await DB.Default.Find<PersonaNote>().Match(n => n.AvatarId == MyId && n.TargetAccountId == id).ExecuteFirstAsync(token);
return new Relationship
{
Id = id,
@@ -485,8 +592,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
Following = following?.State == FollowState.Accepted || reading,
Requested = following?.State == FollowState.Requested,
ShowingReblogs = following?.ShowReblogs ?? false,
Notifying = following?.Notify ?? false,
Languages = following?.Languages,
FollowedBy = followedBy?.IsAccepted == true,
RequestedBy = followedBy is { IsAccepted: false }
RequestedBy = followedBy is { IsAccepted: false },
Endorsed = endorsed,
Note = note?.Text ?? string.Empty
};
}
}