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:
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user