using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using MongoDB.Entities; using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; using PrivaPub.Domain.Statuses; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Outbox; using PrivaPub.StaticServices; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Api.Mastodon.Controllers { public class StatusesController : MastodonController { const int MaxContext = 200; public const int MaxPins = 5; readonly IStatusService _statuses; readonly MastodonMapper _mapper; readonly DbEntities _dbEntities; readonly IMemoryCache _cache; readonly IQuoteService _quotes; readonly IOutboxPublisher _outbox; readonly PrivaPub.Infrastructure.Jobs.IJobQueue _jobs; readonly Domain.Media.IMediaService _media; readonly IParticipations _participations; readonly Federation.Actors.IWalls _walls; public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes, IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default, Federation.Actors.IWalls walls = default) { _walls = walls; _participations = participations; _jobs = jobs; _media = media; _quotes = quotes; _outbox = outbox; _statuses = statuses; _mapper = mapper; _dbEntities = dbEntities; _cache = cache; } [HttpPost("/api/v1/statuses"), Scope("write:statuses")] public async Task Create(CancellationToken token) { var idempotency = Request.Headers["Idempotency-Key"].ToString(); var cacheKey = string.IsNullOrEmpty(idempotency) ? default : $"idempotency:{MyId}:{idempotency}"; if (cacheKey != default && _cache.TryGetValue(cacheKey, out string earlier)) { var existing = await _dbEntities.Posts.MatchID(earlier).ExecuteFirstAsync(token); if (existing != default) return Json(await _mapper.Status(existing, MyId, token)); } var asked = new ScheduledParams { Text = Params.Get("status"), SpoilerText = Params.Get("spoiler_text"), Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive, Visibility = Params.Get("visibility") ?? Me.Settings.DefaultVisibility, InReplyToId = Params.Get("in_reply_to_id"), Language = Params.Get("language") ?? Me.Settings.DefaultLanguage, MediaIds = Params.List("media_ids").ToList(), QuotedStatusId = Params.Get("quoted_status_id"), QuotePolicy = Params.Get("quote_approval_policy"), Idempotency = string.IsNullOrEmpty(idempotency) ? default : idempotency, Poll = Params.Has("poll[options]") ? new ScheduledPoll { Options = Params.List("poll[options]").ToList(), ExpiresIn = Params.Int("poll[expires_in]") ?? 0, Multiple = Params.Bool("poll[multiple]") == true, HideTotals = Params.Bool("poll[hide_totals]") == true } : default }; if (Params.Get("scheduled_at") is { Length: > 0 } scheduledAt) return await Schedule(asked, scheduledAt, cacheKey, token); var outcome = await _statuses.Publish(Me, ScheduledStatuses.Draft(asked), token); if (!outcome.Ok) return Error(outcome.Status, outcome.Error); if (cacheKey != default) _cache.Set(cacheKey, outcome.Post.ID, TimeSpan.FromHours(1)); return Json(await _mapper.Status(outcome.Post, MyId, token)); } // scheduled_at: the post waits, as asked, for a PublishScheduled job at that time; what it would need then (words, // media of the persona's own, not yet posted) is checked now async Task Schedule(ScheduledParams asked, string scheduledAt, string cacheKey, CancellationToken token) { if (cacheKey != default && _cache.TryGetValue(cacheKey + ":scheduled", out string earlier) && await DB.Default.Find().MatchID(earlier).ExecuteFirstAsync(token) is { } waiting) return Json(await ScheduledStatusesController.View(waiting, _media, token)); if (!DateTime.TryParse(scheduledAt, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at)) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid"); if (await ScheduledStatuses.Refusal(MyId, at, default, token) is { } refusal) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal); if (string.IsNullOrWhiteSpace(asked.Text) && asked.MediaIds.Count == 0 && asked.Poll == default) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank"); var mediaIds = asked.MediaIds.Distinct().ToList(); if (mediaIds.Count > 4 || mediaIds.Count > 0 && await DB.Default.CountAsync( m => mediaIds.Contains(m.ID) && m.OwnerAvatarId == MyId && m.PostId == null && m.ScheduledStatusId == null && m.TrashedAt == null && m.ProfileOfAvatarId == null, token) != mediaIds.Count) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many"); var scheduled = new ScheduledStatus { AvatarId = MyId, ScheduledAt = at, Params = asked }; scheduled.ID = (string)scheduled.GenerateNewID(); // reserved in one conditional update: a post or another scheduled post taking one meanwhile wins if (mediaIds.Count > 0) { var reserved = await DB.Default.Update() .Match(m => mediaIds.Contains(m.ID) && m.PostId == null && m.ScheduledStatusId == null && m.TrashedAt == null) .Modify(m => m.ScheduledStatusId, scheduled.ID).ExecuteAsync(token); if (reserved.MatchedCount != mediaIds.Count) { await DB.Default.Update().Match(m => m.ScheduledStatusId == scheduled.ID) .Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token); return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many"); } } await DB.Default.SaveAsync(scheduled, token); await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token); if (cacheKey != default) _cache.Set(cacheKey + ":scheduled", scheduled.ID, TimeSpan.FromHours(1)); return Json(await ScheduledStatusesController.View(scheduled, _media, token)); } [HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Many(CancellationToken token) { var ids = Params.List("id").Take(20).ToList(); var posts = await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token); var visible = new List(); foreach (var post in posts) if (await VisibilityPolicy.CanSee(post, MyId, token)) visible.Add(post); return Json(await _mapper.Statuses(visible, MyId, token)); } [HttpGet("/api/v1/statuses/{id}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Get(string id, CancellationToken token) { var post = await Visible(id, token); var status = post == default ? default : await _mapper.Status(post, MyId, token); return status == default ? NotFoundError() : Json(status); } [HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")] public async Task Edit(string id, CancellationToken token) { var outcome = await _statuses.Edit(Me, id, new StatusDraft { Text = Params.Get("status"), PlainText = true, SpoilerText = Params.Get("spoiler_text"), Sensitive = Params.Bool("sensitive") ?? false, Language = Params.Get("language"), MediaIds = Params.Has("media_ids") ? Params.List("media_ids") : default, MediaChanges = MediaAttributes() }, token); return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); } // Mastodon's media_attributes: a JSON body's array of objects, or a form's media_attributes[i][field] or // media_attributes[][field] (each field then listed in the same order) IReadOnlyList MediaAttributes() { var changes = new List(); if (Params.Json is { ValueKind: System.Text.Json.JsonValueKind.Object } json && json.TryGetProperty("media_attributes", out var array) && array.ValueKind == System.Text.Json.JsonValueKind.Array) { foreach (var item in array.EnumerateArray().Where(i => i.ValueKind == System.Text.Json.JsonValueKind.Object).Take(4)) { var hasDescription = item.TryGetProperty("description", out var description); changes.Add(new MediaChange(Text(item, "id"), hasDescription, hasDescription ? Text(item, "description") : default, Text(item, "focus"))); } return changes; } for (var i = 0; i < 4 && Params.Get($"media_attributes[{i}][id]") is { } id; i++) changes.Add(new MediaChange(id, Params.Has($"media_attributes[{i}][description]"), Params.Get($"media_attributes[{i}][description]"), Params.Get($"media_attributes[{i}][focus]"))); var ids = Params.List("media_attributes[][id]"); var descriptions = Params.List("media_attributes[][description]"); var foci = Params.List("media_attributes[][focus]"); for (var i = 0; i < Math.Min(ids.Count, 4); i++) changes.Add(new MediaChange(ids[i], i < descriptions.Count, i < descriptions.Count ? descriptions[i] : default, i < foci.Count ? foci[i] : default)); return changes; static string Text(System.Text.Json.JsonElement item, string name) => item.TryGetProperty(name, out var value) ? value.ValueKind == System.Text.Json.JsonValueKind.String ? value.GetString() : value.ValueKind is System.Text.Json.JsonValueKind.Null ? default : value.GetRawText() : default; } [HttpDelete("/api/v1/statuses/{id}"), Scope("write:statuses")] public async Task Delete(string id, CancellationToken token) { var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); if (before == default) return await OffWall(id, token); var status = await _mapper.Status(before, MyId, token); var outcome = await _statuses.Remove(Me, id, token); if (!outcome.Ok) return Error(outcome.Status, outcome.Error); if (status != default) { status.Text = before.Text; status.SpoilerText = before.SpoilerText ?? string.Empty; } return Json(status); } // someone's post on the persona's wall (FEP-400e), taken off it: as on Smithereen, the owner of a wall deletes what is on it async Task OffWall(string id, CancellationToken token) { var walled = _walls == default ? default : await _dbEntities.Posts.Match(p => p.ID == id && p.WallOwnerAccountId == MyId && p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); if (walled == default) return NotFoundError(); var status = await _mapper.Status(walled, MyId, token); if (!await _walls.Remove(Me, walled, token)) return NotFoundError(); return Json(status); } [HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Context(string id, CancellationToken token) { var post = await Visible(id, token); if (post == default) return NotFoundError(); var ancestors = new List(); var cursor = post; while (!string.IsNullOrEmpty(cursor.AnsweringToPostId) && ancestors.Count < MaxContext) { cursor = await _dbEntities.Posts.MatchID(cursor.AnsweringToPostId).ExecuteFirstAsync(token); if (cursor == default || cursor.DeletedAt.HasValue || !await VisibilityPolicy.CanSee(cursor, MyId, token)) break; ancestors.Insert(0, cursor); } var descendants = new List(); var frontier = new List { post.ID }; while (frontier.Count > 0 && descendants.Count < MaxContext) { var parents = frontier; var children = await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && !p.DeletedAt.HasValue && p.ReblogOfPostId == null) .Sort(p => p.ID, Order.Ascending).Limit(MaxContext).ExecuteAsync(token); frontier = new List(); foreach (var child in children) if (await VisibilityPolicy.CanSee(child, MyId, token)) { descendants.Add(child); frontier.Add(child.ID); } } // a persona opening a public remote thread has its replies read from their servers, for the next look (once an hour) if (MyId != default) await _jobs.EnqueueMany(new[] { post, ancestors.FirstOrDefault() } .Where(p => p is { IsFederatedCopy: true, Visibility: PostVisibility.Public or PostVisibility.Unlisted }).DistinctBy(p => p.ID) .Select(p => Federation.Inbox.RepliesJobHandler.For(p, 1)), token); return Json(new Context { Ancestors = await _mapper.Statuses(ancestors, MyId, token), Descendants = await _mapper.Statuses(descendants.OrderBy(d => d.ID, StringComparer.Ordinal).ToList(), MyId, token) }); } [HttpGet("/api/v1/statuses/{id}/history"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task History(string id, CancellationToken token) { var post = await Visible(id, token); if (post == default) return NotFoundError(); var account = await _mapper.Account(MastodonMapper.AuthorOf(post), token); var edits = post.Revisions.Select(r => new StatusEdit { Content = r.ContentHtml ?? string.Empty, SpoilerText = r.SpoilerText ?? string.Empty, Sensitive = r.HasContentWarning, CreatedAt = MastodonJson.Time(r.EditedAt), Account = account }).ToList(); edits.Add(new StatusEdit { Content = MastodonMapper.Content(post), SpoilerText = post.SpoilerText ?? string.Empty, Sensitive = post.HasContentWarning, CreatedAt = MastodonJson.Time(post.EditedAt ?? post.CreationDate), Account = account }); return Json(edits); } [HttpGet("/api/v1/statuses/{id}/source"), Scope("read:statuses")] public async Task Source(string id, CancellationToken token) { var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty }); } // a persona joins or leaves a remote event (owner decision 2026-10-05); the status says how it stands // (privapub.event.participation) [HttpPost("/api/privapub/v1/statuses/{id}/join"), Scope("write:statuses")] public async Task Join(string id, CancellationToken token) { var outcome = await _participations.Join(Me, id, token); return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); } [HttpPost("/api/privapub/v1/statuses/{id}/leave"), Scope("write:statuses")] public async Task Leave(string id, CancellationToken token) { var outcome = await _participations.Leave(Me, id, token); return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); } [HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")] public Task Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token); [HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")] public Task Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token); // PrivaPub's own, for the threadiverse's downvotes (a favourite is the upvote) [HttpPost("/api/v1/statuses/{id}/downvote"), Scope("write:favourites")] public Task Downvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, true, token), id, token); [HttpPost("/api/v1/statuses/{id}/undownvote"), Scope("write:favourites")] public Task Undownvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, false, token), id, token); [HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")] public async Task Reblog(string id, CancellationToken token) { var outcome = await _statuses.Reblog(Me, id, true, Visibility(Params.Get("visibility")), token); return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); } [HttpPost("/api/v1/statuses/{id}/unreblog"), Scope("write:statuses")] public Task Unreblog(string id, CancellationToken token) => Toggle(_statuses.Reblog(Me, id, false, PostVisibility.Public, token), id, token); [HttpGet("/api/v1/statuses/{id}/favourited_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task FavouritedBy(string id, CancellationToken token) { if (await Visible(id, token) == default) return NotFoundError(); var favourites = await _dbEntities.Favourites.Match(f => f.PostId == id).Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token); var accounts = await _mapper.Accounts(favourites.Select(f => f.AccountId), token); return Json(favourites.Select(f => accounts.GetValueOrDefault(f.AccountId)).Where(a => a != default).ToList()); } [HttpGet("/api/v1/statuses/{id}/reblogged_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task RebloggedBy(string id, CancellationToken token) { if (await Visible(id, token) == default) return NotFoundError(); var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token); var accounts = await _mapper.Accounts(reblogs.Select(MastodonMapper.AuthorOf), token); return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList()); } [HttpGet("/api/v1/statuses/{id}/quotes"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Quotes(string id, CancellationToken token) { if (await Visible(id, token) == default) return NotFoundError(); var query = _dbEntities.Posts.Match(p => p.QuotedPostId == id && p.QuoteState == QuoteState.Accepted && !p.DeletedAt.HasValue); var quotes = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token); var visible = new List(); foreach (var quote in quotes) if (quote.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quote, MyId, token)) visible.Add(quote); Link($"/api/v1/statuses/{id}/quotes", quotes.LastOrDefault()?.ID, quotes.FirstOrDefault()?.ID); return Json(await _mapper.Statuses(visible, MyId, token)); } [HttpPost("/api/v1/statuses/{id}/quotes/{quotingId}/revoke"), Scope("write:statuses")] public async Task RevokeQuote(string id, string quotingId, CancellationToken token) { var quoted = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var quoting = quoted == default ? default : await _dbEntities.Posts.Match(p => p.ID == quotingId && p.QuotedPostId == quoted.ID).ExecuteFirstAsync(token); if (quoting == default || !await _quotes.RevokeLicence(quoted, quoting, token)) return NotFoundError(); return Json(await _mapper.Status(await _dbEntities.Posts.MatchID(quoting.ID).ExecuteFirstAsync(token), MyId, token)); } [HttpPut("/api/v1/statuses/{id}/interaction_policy"), Scope("write:statuses")] public async Task InteractionPolicy(string id, CancellationToken token) { var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); if (post == default) return NotFoundError(); var policy = Params.Get("quote_approval_policy"); if (!QuotePolicies.IsKnown(policy)) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Unknown quote approval policy"); await DB.Default.Update().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token); post.LocalQuotePolicy = policy; if (!post.IsLocalOnly) await _outbox.PublishUpdate(Me, post, $"policy-{DateTime.UtcNow.Ticks}", token); return Json(await _mapper.Status(post, MyId, token)); } [HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")] public async Task Bookmark(string id, CancellationToken token) { var post = await Visible(id, token); if (post == default) return NotFoundError(); try { await DB.Default.SaveAsync(new Models.Social.Bookmark { AvatarId = MyId, PostId = post.ID }, token); } catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey) { } return Json(await _mapper.Status(post, MyId, token)); } [HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")] public async Task Unbookmark(string id, CancellationToken token) { await DB.Default.DeleteAsync(b => b.AvatarId == MyId && b.PostId == id); return await Unchanged(id, token); } [HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")] public async Task Bookmarks(CancellationToken token) { var bookmarks = await Page.From(Params, Limit()).Fetch(DB.Default.Find().Match(b => b.AvatarId == MyId), b => b.ID, token); var ids = bookmarks.Select(b => b.PostId).ToList(); var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID); var visible = new List(); foreach (var postId in ids.Where(posts.ContainsKey)) if (await VisibilityPolicy.CanSee(posts[postId], MyId, token)) visible.Add(posts[postId]); Link("/api/v1/bookmarks", bookmarks.LastOrDefault()?.ID, bookmarks.FirstOrDefault()?.ID); return Json(await _mapper.Statuses(visible, MyId, token)); } [HttpPost("/api/v1/statuses/{id}/mute"), Scope("write:mutes")] public Task Mute(string id, CancellationToken token) => Unchanged(id, token); [HttpPost("/api/v1/statuses/{id}/unmute"), Scope("write:mutes")] public Task Unmute(string id, CancellationToken token) => Unchanged(id, token); [HttpPost("/api/v1/statuses/{id}/pin"), Scope("write:accounts")] public async Task Pin(string id, CancellationToken token) { var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null) .ExecuteFirstAsync(token); if (post == default) return await Visible(id, token) is { } other ? Error(StatusCodes.Status422UnprocessableEntity, other.ReblogOfPostId == null ? "Validation failed: Someone else's post cannot be pinned" : "Validation failed: A boost cannot be pinned") : NotFoundError(); if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned"); if (await DB.Default.CountAsync(p => p.AvatarId == MyId, token) >= MaxPins) return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: You can pin at most {MaxPins} posts"); try { await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token); await _outbox.PublishFeatured(MyId, post, featured: true, token); } catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey) { } return Json(await _mapper.Status(post, MyId, token)); } [HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")] public async Task Unpin(string id, CancellationToken token) { var unpinned = await DB.Default.DeleteAsync(p => p.AvatarId == MyId && p.PostId == id); if (unpinned is { IsAcknowledged: true, DeletedCount: > 0 } && await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token) is { } post) await _outbox.PublishFeatured(MyId, post, featured: false, token); return await Unchanged(id, token); } async Task Toggle(Task action, string id, CancellationToken token) { var outcome = await action; if (!outcome.Ok) return Error(outcome.Status, outcome.Error); var post = await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token); return Json(await _mapper.Status(post, MyId, token)); } async Task Unchanged(string id, CancellationToken token) { var post = await Visible(id, token); var status = post == default ? default : await _mapper.Status(post, MyId, token); return status == default ? NotFoundError() : Json(status); } async Task Visible(string id, CancellationToken token) { var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default; } static PostVisibility Visibility(string value) => ScheduledStatuses.Visibility(value); } }