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
};
}
}
@@ -0,0 +1,113 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Featured hashtags as Mastodon's API has them (completion program R8, Domain/Social/FeaturedTags): the persona's own,
// featured and unfeatured (its followers are told), suggested from the tags it uses, and any account's: a persona's own
// here, or those an account elsewhere features as its server tells them. They are public, as on Mastodon.
public class FeaturedTagsController : MastodonController
{
const int MaxSuggested = 10;//Mastodon's RECENT_TAGS_LIMIT
const int SuggestionPosts = 200;
readonly IFeaturedTags _featured;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public FeaturedTagsController(IFeaturedTags featured, DbEntities dbEntities, ILocalActorService localActors)
{
_featured = featured;
_dbEntities = dbEntities;
_localActors = localActors;
}
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
public async Task<IActionResult> Mine(CancellationToken token) => Json(await Entities(await _featured.Of(MyId, token), token));
[HttpPost("/api/v1/featured_tags"), Scope("write:accounts")]
public async Task<IActionResult> Feature(CancellationToken token)
{
var (tag, error) = await _featured.Feature(Me, Params.Get("name"), token);
return tag == default ? Error(StatusCodes.Status422UnprocessableEntity, error) : Json((await Entities(new List<FeaturedTag> { tag }, token))[0]);
}
[HttpDelete("/api/v1/featured_tags/{id}"), Scope("write:accounts")]
public async Task<IActionResult> Unfeature(string id, CancellationToken token) =>
await _featured.Unfeature(Me, id, default, token) == default ? NotFoundError() : Json(new { });
// the tags the persona used lately and does not feature yet, the most used first, as Mastodon's Tag
[HttpGet("/api/v1/featured_tags/suggestions"), Scope("read:accounts")]
public async Task<IActionResult> Suggestions(CancellationToken token)
{
var featured = (await _featured.Of(MyId, token)).Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
var recent = await _dbEntities.Posts.Match(p => p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null
&& p.Tags.Count > 0)
.Sort(p => p.ID, Order.Descending).Limit(SuggestionPosts).Project(p => p.Include(x => x.Tags)).ExecuteAsync(token);
var used = recent.SelectMany((p, age) => p.Tags.Distinct().Select(tag => (tag, age)))
.Where(t => !featured.Contains(t.tag) && TagsController.IsHashtag(t.tag))
.GroupBy(t => t.tag)
.OrderByDescending(g => g.Count()).ThenBy(g => g.Min(t => t.age))
.Take(MaxSuggested)
.Select(g => g.Key)
.ToList();
var views = new List<object>();
foreach (var tag in used)
views.Add(await TagsController.TagView(_dbEntities, _localActors.BaseAddress, tag, MyId, default, false, token));
return Json(views);
}
// what an account features: a persona's own hashtags, or what an account elsewhere tells (its featuredTags, its Add
// and Remove); a community features none. A suspended account's answer 404, as its account does (R6).
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> OfAccount(string id, CancellationToken token)
{
var avatar = await _dbEntities.Avatars.MatchID(id).Match(a => !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (avatar != default)
return _localActors.FromAvatar(avatar) is { Suspended: false }
? Json(await Entities(await _featured.Of(avatar.ID, token), token))
: NotFoundError();
if (await _dbEntities.Groups.MatchID(id).Match(g => !g.DeletionAt.HasValue).ExecuteAnyAsync(token))
return Json(Array.Empty<FeaturedTagEntity>());
var remote = await _dbEntities.ForeignAvatars.MatchID(id).Match(f => !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (remote == default || remote.SuspendedAt.HasValue)
return NotFoundError();
var tags = await DB.Default.Find<RemoteFeaturedTag>().Match(t => t.ActorURI == remote.ActorURI).Sort(t => t.Position, Order.Ascending)
.Limit(FeaturedTags.MaxRemote).ExecuteAsync(token);
var usage = await FeaturedTags.Usage(default, remote.ActorURI, tags.Select(t => t.Name).ToList(), _dbEntities, token);
return Json(tags.Select(t => Entity(t.ID, t.Name, t.DisplayName, t.Href, usage.GetValueOrDefault(t.Name))).ToList());
}
async Task<List<FeaturedTagEntity>> Entities(List<FeaturedTag> tags, CancellationToken token)
{
var byPersona = tags.GroupBy(t => t.AvatarId).ToList();
var entities = new List<FeaturedTagEntity>();
foreach (var persona in byPersona)
{
var usage = await FeaturedTags.Usage(persona.Key, default, persona.Select(t => t.Name).ToList(), _dbEntities, token);
entities.AddRange(persona.Select(t => Entity(t.ID, t.Name, t.DisplayName, default, usage.GetValueOrDefault(t.Name))));
}
return entities;
}
FeaturedTagEntity Entity(string id, string name, string displayName, string href, (long Count, DateTime? Last) usage) => new()
{
Id = id,
Name = string.IsNullOrEmpty(displayName) ? name : displayName,
// (a link to the remote site is only a link, which the reader follows or not; ours is this server's tag page)
Url = href ?? ActivityPubRenderer.TagUrl(_localActors.BaseAddress, name),
StatusesCount = usage.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
LastStatusAt = usage.Last?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)
};
}
}
@@ -134,7 +134,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
registrations = Open,
approval_required = false,
invites_enabled = true,//a group invitation always creates an account
configuration = new { accounts = new { max_featured_tags = 0 }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
configuration = new { accounts = new { max_featured_tags = PrivaPub.Domain.Social.FeaturedTags.MaxFeatured }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
contact_account = await ContactAccount(settings, token),
rules = Rules(settings)
});
@@ -161,7 +161,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
configuration = new
{
urls = new { streaming = Streaming },
accounts = new { max_featured_tags = 0, max_pinned_statuses = StatusesController.MaxPins },
accounts = new { max_featured_tags = PrivaPub.Domain.Social.FeaturedTags.MaxFeatured, max_pinned_statuses = StatusesController.MaxPins },
statuses = Statuses,
media_attachments = MediaAttachments,
polls = Polls,
@@ -140,28 +140,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
public class StubsController : MastodonController
{
[HttpGet("/api/v1/suggestions"), Scope("read")]
public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
[HttpGet("/api/v2/suggestions"), Scope("read")]
public IActionResult SuggestionsV2() => Json(Array.Empty<object>());
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
public IActionResult Endorsements() => Json(Array.Empty<object>());
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
public IActionResult FeaturedTags() => Json(Array.Empty<object>());
[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"] = "default",
["reading:expand:spoilers"] = false
});
[HttpGet("/api/v1/push/subscription"), Scope("push")]
public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found");
@@ -179,5 +157,21 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/instance/translation_languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TranslationLanguages() => Json(new { });
// translation is cut (owner decision 2026-10-17): no language offers any (translation_languages), and a client that
// asks anyway is told so, as Mastodon tells it when it can't translate
[HttpPost("/api/v1/statuses/{id}/translate"), Scope("read:statuses")]
public IActionResult Translate(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Translation is not available on this server");
// Mastodon 4.3's yearly reports (Wrapstodon): none are made here, so there are never any to show
[HttpGet("/api/v1/annual_reports"), Scope("read:accounts")]
public IActionResult AnnualReports() => Json(new { annual_reports = Array.Empty<object>(), accounts = Array.Empty<object>(), statuses = Array.Empty<object>() });
// the Mastodon admin API is cut: moderation stays on /clientapi (ModeratorController, AdminController), so every
// admin route answers as Mastodon answers an account that may not use it, whoever asks
[HttpGet("/api/v1/admin/{**rest}"), HttpPost("/api/v1/admin/{**rest}"), HttpPut("/api/v1/admin/{**rest}"), HttpPatch("/api/v1/admin/{**rest}"),
HttpDelete("/api/v1/admin/{**rest}"), HttpGet("/api/v2/admin/{**rest}"), HttpPost("/api/v2/admin/{**rest}"),
ApiExplorerSettings(IgnoreApi = true), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Admin(string rest) => Error(StatusCodes.Status403Forbidden, "This action is not allowed");
}
}
@@ -41,7 +41,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
{
switch (name)
{
case Streams.User or Streams.UserNotification or Streams.Public or Streams.PublicLocal or Streams.PublicRemote:
case Streams.User or Streams.UserNotification or Streams.Public or Streams.PublicLocal or Streams.PublicRemote or Streams.Direct:
return (new Streams.Key(name), default);
case Streams.Hashtag or Streams.HashtagLocal:
var normal = TagsController.Normalise(tag);
@@ -69,6 +69,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
}
if (e.Name == "announcement.reaction")
return JsonSerializer.Serialize(await AnnouncementsController.ReactionEvent(e.Id, e.Data, token), MastodonJson.Options);
// a direct conversation the persona takes part in (R8): Mastodon's Conversation, as /api/v1/conversations lists it
if (e.Name == "conversation")
{
var group = await _dbEntities.DmGroups.Match(g => g.ID == e.Id && !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
.ExecuteFirstAsync(token);
var conversation = group == default ? default
: (await TimelinesController.ConversationsOf(_mapper, _dbEntities, MyId, new List<Models.Group.DmGroup> { group }, token)).FirstOrDefault();
return conversation == default ? default : JsonSerializer.Serialize(conversation, MastodonJson.Options);
}
if (e.Name == "notification")
{
var notification = await _dbEntities.Notifications.Match(n => n.ID == e.Id && n.AvatarId == MyId).ExecuteFirstAsync(token);
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Instance;
using PrivaPub.Domain.Relationships;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Social;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Follow suggestions as Mastodon's API has them (completion program R8; who and why in Domain/Social/Suggestions): v1 the
// accounts, v2 each with its reasons; a dismissed account is never suggested to that persona again.
public class SuggestionsController : MastodonController
{
readonly MastodonMapper _mapper;
readonly IInstanceSettings _settings;
public SuggestionsController(MastodonMapper mapper, IInstanceSettings settings)
{
_mapper = mapper;
_settings = settings;
}
[HttpGet("/api/v1/suggestions"), Scope("read")]
public async Task<IActionResult> V1(CancellationToken token) => Json((await Suggested(token)).Select(s => s.Account).ToList());
[HttpGet("/api/v2/suggestions"), Scope("read")]
public async Task<IActionResult> V2(CancellationToken token) => Json(await Suggested(token));
[HttpDelete("/api/v1/suggestions/{accountId}"), Scope("read")]
public async Task<IActionResult> Dismiss(string accountId, CancellationToken token)
{
if (!string.IsNullOrEmpty(accountId) && accountId.Length <= 64)
try
{
await DB.Default.Update<SuggestionDismissal>().Match(d => d.AvatarId == MyId && d.AccountId == accountId)
.Modify(b => b.SetOnInsert(d => d.CreatedAt, DateTime.UtcNow)).Option(o => o.IsUpsert = true).ExecuteAsync(token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
}
return Json(new { });
}
// the candidates mapped a few at a time, until the page is full: an account no longer shown (deleted, suspended), one
// shown limited (silenced, or its server), a community outside the staff's picks, and one the persona hides (its
// blocked servers) are passed over
async Task<List<Suggestion>> Suggested(CancellationToken token)
{
var limit = Limit(40, 80);
var candidates = await Suggestions.For(Me, _settings.Current.StaffPicks, token);
var suggested = new List<Suggestion>();
for (var start = 0; start < candidates.Count && suggested.Count < limit; start += limit * 2)
{
var chunk = candidates.Skip(start).Take(limit * 2).ToList();
var accounts = await _mapper.Accounts(chunk.Select(c => c.AccountId), token);
var hidden = await Hidden.AuthorsHiddenFrom(MyId, accounts.Values.Select(a => a.Uri), forNotifications: false, token);
foreach (var candidate in chunk)
{
if (accounts.GetValueOrDefault(candidate.AccountId) is not { } account || account.Limited == true || hidden.Contains(account.Uri)
|| account.Group && candidate.Source != Suggestions.Staff)
continue;
suggested.Add(new Suggestion { Source = candidate.Source, Sources = candidate.Sources, Account = account });
if (suggested.Count >= limit)
break;
}
}
return suggested;
}
}
}
@@ -59,12 +59,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
}).ToList();
}
async Task<object> View(string tag, bool? following, CancellationToken token) => new
Task<object> View(string tag, bool? following, CancellationToken token, bool? featuring = default) =>
TagView(_dbEntities, _localActors.BaseAddress, tag, MyId, following, featuring, token);
// Mastodon's Tag: its week of use, and for a signed-in persona whether it follows it and (Mastodon 4.4) features it
internal static async Task<object> TagView(DbEntities dbEntities, string baseAddress, string tag, string viewerId, bool? following, bool? featuring,
CancellationToken token) => new
{
name = tag,
url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}",
history = await History(_dbEntities, tag, token),
following = following ?? (MyId != default && await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId && t.Name == tag).ExecuteAnyAsync(token))
url = $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}",
history = await History(dbEntities, tag, token),
following = following ?? (viewerId != default && await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == viewerId && t.Name == tag).ExecuteAnyAsync(token)),
featuring = viewerId == default ? default(bool?)
: featuring ?? await DB.Default.Find<FeaturedTag>().Match(t => t.AvatarId == viewerId && t.Name == tag).ExecuteAnyAsync(token)
};
[HttpGet("/api/v1/tags/{name}"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
@@ -103,6 +110,24 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(await View(tag, false, token));
}
// Mastodon 4.4: a hashtag featured on the persona's profile, or no longer (R8, Domain/Social/FeaturedTags)
[HttpPost("/api/v1/tags/{name}/feature"), Scope("write:accounts")]
public async Task<IActionResult> Feature(string name, [FromServices] Domain.Social.IFeaturedTags featured, CancellationToken token)
{
var (tag, error) = await featured.Feature(Me, name, token);
return tag == default ? Error(StatusCodes.Status422UnprocessableEntity, error) : Json(await View(tag.Name, default, token, featuring: true));
}
[HttpPost("/api/v1/tags/{name}/unfeature"), Scope("write:accounts")]
public async Task<IActionResult> Unfeature(string name, [FromServices] Domain.Social.IFeaturedTags featured, CancellationToken token)
{
var tag = Normalise(name);
if (!IsHashtag(tag))
return NotFoundError();
await featured.Unfeature(Me, default, tag, token);
return Json(await View(tag, default, token, featuring: false));
}
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
public async Task<IActionResult> Followed(CancellationToken token)
{
@@ -123,35 +123,39 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(await ConversationsOf(groups, token));
}
async Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token)
Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token) => ConversationsOf(_mapper, _dbEntities, MyId, groups, token);
// (also the `direct` stream's conversation event, StreamingController)
internal static async Task<List<Conversation>> ConversationsOf(MastodonMapper mapper, DbEntities dbEntities, string viewerId, List<DmGroup> groups,
CancellationToken token)
{
var ids = groups.Select(g => g.ID).ToList();
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == MyId && ids.Contains(s.ConversationId)).ExecuteAsync(token))
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == viewerId && ids.Contains(s.ConversationId)).ExecuteAsync(token))
.ToDictionary(s => s.ConversationId);
groups = groups.Where(g => !ConversationStates.IsHidden(states.GetValueOrDefault(g.ID), g.LastPostId)).ToList();
// the newest post each conversation still shows: usually its LastPostId, else (deleted) the one before
var newestIds = groups.Select(g => g.LastPostId).ToList();
var newest = (await _dbEntities.Posts.Match(p => newestIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
var newest = (await dbEntities.Posts.Match(p => newestIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
.ToDictionary(p => p.ConversationId);
foreach (var group in groups.Where(g => !newest.ContainsKey(g.ID)))
if (await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending)
if (await dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending)
.ExecuteFirstAsync(token) is { } shown)
newest[group.ID] = shown;
var foreignUris = groups.SelectMany(g => g.Members).Where(m => m.IsForeign).Select(m => m.AvatarId).Distinct().ToList();
var foreignIds = (await _dbEntities.ForeignAvatars.Match(f => foreignUris.Contains(f.ActorURI)).ExecuteAsync(token))
var foreignIds = (await dbEntities.ForeignAvatars.Match(f => foreignUris.Contains(f.ActorURI)).ExecuteAsync(token))
.GroupBy(f => f.ActorURI).ToDictionary(g => g.Key, g => g.First().ID);
List<string> Others(DmGroup group) => group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign))
List<string> Others(DmGroup group) => group.Members.Where(m => !(m.AvatarId == viewerId && !m.IsForeign))
.Select(m => m.IsForeign ? foreignIds.GetValueOrDefault(m.AvatarId) : m.AvatarId).Where(id => id != default).ToList();
var accounts = await _mapper.Accounts(groups.SelectMany(Others).Distinct(), token);
var statuses = (await _mapper.Statuses(newest.Values.ToList(), MyId, token)).ToDictionary(s => s.Id);
var accounts = await mapper.Accounts(groups.SelectMany(Others).Distinct(), token);
var statuses = (await mapper.Statuses(newest.Values.ToList(), viewerId, token)).ToDictionary(s => s.Id);
return groups.Where(g => newest.ContainsKey(g.ID) && statuses.ContainsKey(newest[g.ID].ID))
.Select(g => new Conversation
{
Id = g.ID,
Unread = ConversationStates.IsUnread(states.GetValueOrDefault(g.ID), newest[g.ID].ID, newest[g.ID].AuthorAccountId, MyId),
Unread = ConversationStates.IsUnread(states.GetValueOrDefault(g.ID), newest[g.ID].ID, newest[g.ID].AuthorAccountId, viewerId),
Accounts = Others(g).Select(id => accounts.GetValueOrDefault(id)).Where(a => a != default).ToList(),
LastStatus = statuses[newest[g.ID].ID]
})
@@ -240,7 +244,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
[NotificationType.Poll] = "poll",
[NotificationType.Reaction] = "pleroma:emoji_reaction",
[NotificationType.Quote] = "quote",
[NotificationType.ModerationWarning] = "moderation_warning"
[NotificationType.ModerationWarning] = "moderation_warning",
[NotificationType.Status] = "status"//an account followed with notify posted (R8)
};
readonly MastodonMapper _mapper;