diff --git a/PrivaPub.Tests/Http/MediaLifecycleTests.cs b/PrivaPub.Tests/Http/MediaLifecycleTests.cs index c049d83..c2dbc9b 100644 --- a/PrivaPub.Tests/Http/MediaLifecycleTests.cs +++ b/PrivaPub.Tests/Http/MediaLifecycleTests.cs @@ -74,6 +74,38 @@ namespace PrivaPub.Tests.Http Assert.Equal("edited out", (await DB.Default.Find().OneAsync(dropped, Token)).TrashReason); } + // a posted picture's description and focus change by editing its post (media_attributes), not by PUT, as on Mastodon + [Fact] + public async Task An_edit_describes_its_media_again_and_keeps_what_it_is_not_sent() + { + var alice = await _host.Mastodon("alice"); + var id = await Upload(alice, "a.jpg"); + var status = (await alice.Client.Post("/api/v1/statuses", ("status", "described later"), ("media_ids[]", id), + ("spoiler_text", "a warning"), ("sensitive", "true"), ("language", "it"))).Ok(); + var statusId = status.Body.Text("id"); + Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Put($"/api/v1/media/{id}", ("description", "too late"))).Status); + + var edited = (await alice.Client.Json(HttpMethod.Put, $"/api/v1/statuses/{statusId}", new System.Text.Json.Nodes.JsonObject + { + ["status"] = "described now", + ["spoiler_text"] = "a warning", + ["sensitive"] = true, + ["media_ids"] = new System.Text.Json.Nodes.JsonArray(id), + ["media_attributes"] = new System.Text.Json.Nodes.JsonArray(new System.Text.Json.Nodes.JsonObject { ["id"] = id, ["description"] = "a red square", ["focus"] = "0.5,0.5" }) + })).Ok(); + + Assert.Equal("a red square", edited.Body["media_attachments"]![0]!.Text("description")); + Assert.Equal(0.5, edited.Body["media_attachments"]![0]!["meta"]!["focus"]!["x"]!.GetValue()); + Assert.Equal("a warning", edited.Body.Text("spoiler_text")); + Assert.True(edited.Body["sensitive"]!.GetValue()); + Assert.Equal("it", edited.Body.Text("language")); + Assert.Equal("a red square", (await DB.Default.Find().OneAsync(id, Token)).Description); + + // an edit that doesn't send media keeps them, described as they were + var kept = (await alice.Client.Put($"/api/v1/statuses/{statusId}", ("status", "media kept"))).Ok(); + Assert.Equal("a red square", kept.Body["media_attachments"]![0]!.Text("description")); + } + [Fact] public async Task A_replaced_avatar_is_trashed_and_a_picture_that_is_not_one_is_refused() { diff --git a/PrivaPub/Api/Mastodon/Controllers/MediaController.cs b/PrivaPub/Api/Mastodon/Controllers/MediaController.cs index f7108ef..5d09ac9 100644 --- a/PrivaPub/Api/Mastodon/Controllers/MediaController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/MediaController.cs @@ -64,10 +64,11 @@ namespace PrivaPub.Api.Mastodon.Controllers }; } + // as on Mastodon, only media not posted yet: a posted one's description and focus change by editing its post (media_attributes) [HttpPut("/api/v1/media/{id}"), Scope("write:media")] public async Task Update(string id, CancellationToken token) { - var attachment = await DB.Default.Find().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token); + var attachment = await DB.Default.Find().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null && m.PostId == null).ExecuteFirstAsync(token); if (attachment == default) return NotFoundError(); if (Params.Has("description")) diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs index 72f34b9..729e1c0 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -167,11 +167,41 @@ namespace PrivaPub.Api.Mastodon.Controllers SpoilerText = Params.Get("spoiler_text"), Sensitive = Params.Bool("sensitive") ?? false, Language = Params.Get("language"), - MediaIds = Params.Has("media_ids") ? Params.List("media_ids") : default + 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) { diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index 09a06d9..4306c47 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -24,6 +24,9 @@ using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Domain.Statuses { + // an attachment's description or focal point changed by an edit (Mastodon's media_attributes) + public sealed record MediaChange(string Id, bool HasDescription, string Description, string Focus); + public sealed class StatusDraft { public string Text { get; init; } @@ -38,6 +41,7 @@ namespace PrivaPub.Domain.Statuses public string ConversationId { get; init; } public IReadOnlyList Recipients { get; init; } = Array.Empty(); public IReadOnlyList MediaIds { get; init; } + public IReadOnlyList MediaChanges { get; init; } = Array.Empty(); // the scheduled post being published, whose media are reserved for it alone public string ScheduledStatusId { get; init; } public double? Latitude { get; init; } @@ -320,6 +324,23 @@ namespace PrivaPub.Domain.Statuses var plain = draft.PlainText || post.ContentFormat == ContentFormat.Plain; var rendered = plain ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token); + // descriptions and focal points changed by the edit, on the media it keeps or those the post already has + var described = media ?? (draft.MediaChanges.Count > 0 + ? await DB.Default.Find().Match(m => m.PostId == post.ID && m.TrashedAt == null).ExecuteAsync(token) + : default); + foreach (var change in draft.MediaChanges) + { + if (described?.FirstOrDefault(m => m.ID == change.Id) is not { } attachment) + continue; + if (change.HasDescription) + attachment.Description = string.IsNullOrWhiteSpace(change.Description) ? default : change.Description.Trim()[..Math.Min(change.Description.Trim().Length, 1500)]; + if (Domain.Media.FocalPoint.Parse(change.Focus) is { } focus) + attachment.Focus = focus; + await DB.Default.Update().MatchID(attachment.ID) + .Modify(m => m.Description, attachment.Description).Modify(m => m.Focus, attachment.Focus).ExecuteAsync(token); + } + if (media == default && described != default) + post.Media = post.Media.Select(copy => described.FirstOrDefault(m => m.ID == copy.AttachmentId) is { } changed ? ToPostMedia(changed) : copy).ToList(); if (media != default) { if (!await Claim(media, post.ID, default, token)) @@ -330,6 +351,7 @@ namespace PrivaPub.Domain.Statuses await _media.Trash(m => m.PostId == post.ID && !kept.Contains(m.ID), "edited out", token); } post.Title = draft.Title == default ? post.Title : Clean(draft.Title); + // as on Mastodon, an edit sends the whole post: no warning sent is no warning (the language alone falls back) post.SpoilerText = Clean(draft.SpoilerText); post.HasContentWarning = draft.Sensitive || post.SpoilerText != default; post.Text = draft.Text;