From 5c11727f58e6a45cd7986604647102be401d19c7 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 04:42:42 +0200 Subject: [PATCH] Polls, quotes and edits from the composer The composer can hold a poll instead of media (two to four choices, a length, one choice or several), quote a post (offered on a card when the server says the persona may quote it), and edit the persona's own post: its words and warning come from /source, and saving keeps its media, visibility and poll. A card's menu offers Edit and Quote, and now opens sideways: upwards, the first card's menu was cut off by the top of the feed. The e2e tests publish a poll, edit a post and quote it, each checked on PrivaPub. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 3 + Components/Composer.razor | 113 ++++++++++++++++++++++++++--- Components/Content.razor | 48 +++++++++++- MastodonJsonContext.cs | 2 + Models/APIs.cs | 2 + Models/ComposeForm.cs | 9 +++ Models/Mastodon/PrivaPubApi.cs | 26 +++++++ Models/Mastodon/Status.cs | 9 +++ Resources/AllStrings.it.resx | 51 +++++++++++++ Resources/AllStrings.resx | 51 +++++++++++++ Services/StatusService.cs | 27 ++++++- tests/e2e/Actions/ComposerTests.cs | 105 +++++++++++++++++++++++++++ 12 files changed, 434 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/Actions/ComposerTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 0c58b47..1d6bb0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -228,6 +228,9 @@ supplies: lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks, conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs its media (blur). +- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a + card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and + warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media. - **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them. - **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts diff --git a/Components/Composer.razor b/Components/Composer.razor index 3a1720c..deaafbe 100644 --- a/Components/Composer.razor +++ b/Components/Composer.razor @@ -3,7 +3,9 @@ @* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of this device's position and never federated; a group post goes to the group. Both of those go through /clientapi. - A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those. *@ + A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those. + It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words + and warning change, its media, visibility and poll stay). *@ @@ -17,10 +19,49 @@
+ placeholder="@Placeholder" rows="3" data-compose /> @ContentLength/@ComposeForm.MaxContentLength
+ @if (Quoting is not null) + { + + } + + @if (form.HasPoll) + { +
+ @for (var i = 0; i < form.PollOptions.Count; i++) + { + var index = i; +
+ + @if (form.PollOptions.Count > 2) + { + + } +
+ } +
+ @if (form.PollOptions.Count < 4) + { + @Localizer["Add a choice"] + } + + @foreach (var (seconds, label) in PollLengths) + { + + } + + +
+
+ } + @if (form.Media.Count > 0) {
@@ -70,7 +111,7 @@
- @if (!form.IsLocated && GroupId is null) + @if (!form.IsLocated && GroupId is null && Editing is null) { @@ -92,7 +133,7 @@ - @if (!form.IsLocated && GroupId is null) + @if (!form.IsLocated && GroupId is null && Editing is null && !form.HasPoll) { @* A label styled as a button opens the hidden file input. *@
@@ -163,6 +211,10 @@ [Parameter] public Status InReplyTo { get; set; } /// The community or circle the post is for. [Parameter] public string GroupId { get; set; } + /// The post this one quotes (shown under the words). + [Parameter] public Status Quoting { get; set; } + /// The persona's own post being edited: its words come from /source, and saving replaces them. + [Parameter] public Status Editing { get; set; } /// The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API). [Parameter] public EventCallback OnPublished { get; set; } @@ -173,6 +225,22 @@ int ContentLength => form.Content?.Length ?? 0; + string Placeholder => Editing is not null ? Localizer["Edit your post"] + : Quoting is not null ? Localizer["Say something about it"] + : InReplyTo is null ? Localizer["What's happening?"] + : Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct]; + + string SubmitLabel => Editing is not null ? Localizer["Save"] + : form.IsScheduled ? Localizer["Schedule"] + : Quoting is not null ? Localizer["Quote"] + : Localizer[InReplyTo is null ? "Post" : "Reply"]; + + (int Seconds, string Label)[] PollLengths => + [ + (300, Localizer["5 minutes"]), (1800, Localizer["30 minutes"]), (3600, Localizer["1 hour"]), (21600, Localizer["6 hours"]), + (86400, Localizer["1 day"]), (259200, Localizer["3 days"]), (604800, Localizer["7 days"]) + ]; + int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1; long MaximumFileSize => form.MediaType switch @@ -190,10 +258,18 @@ _ => default }; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { - IsLoading = false; form.GroupId = GroupId; + form.QuotedStatusId = Quoting?.Id; + if (Editing is not null) + { + var source = await StatusService.Source(Editing.Id); + form.Content = source?.Text; + form.HasContentWarning = source?.SpoilerText is { Length: > 0 }; + form.SpoilerText = source?.SpoilerText; + } + IsLoading = false; if (InReplyTo is null) return; @@ -297,6 +373,25 @@ error = Localizer["Missing content, either message or media"]; return; } + if (form.HasPoll && form.PollOptions.Count(o => !string.IsNullOrWhiteSpace(o)) < 2) + { + error = Localizer["A poll needs two choices at least."]; + return; + } + if (Editing is not null) + { + isPublishing = true; + var edited = await StatusService.Edit(Editing.Id, form); + isPublishing = false; + if (edited.IsInvalid) + { + error = edited.ErrorMessage; + return; + } + Toast.AddResponseSuccess(Localizer["Saved."]); + await OnPublished.InvokeAsync(FeedPost.Of((Status)edited.Data)); + return; + } if (CascadingState.PersonaId is null) { error = Localizer["Choose a persona first."]; diff --git a/Components/Content.razor b/Components/Content.razor index b22d7d2..459a07b 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -195,9 +195,10 @@
- + ContentClass="mr-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]"> @@ -209,6 +210,13 @@ @Localizer["Open original"] } + @if (CanQuote(status)) + { + + + @Localizer["Quote"] + + } @if (!IsOwn) { @@ -222,6 +230,10 @@ } else { + + + @Localizer["Edit"] + @Localizer["Delete"] @@ -242,6 +254,14 @@ { } + @if (isQuoting) + { + + } + @if (isEditing) + { + + } } } } @@ -292,6 +312,8 @@ [Parameter] public FilterContext? FilterIn { get; set; } bool isReplying; + bool isQuoting; + bool isEditing; bool isActing; bool isOptionsOpen; bool isSpoilerOpen; @@ -585,6 +607,28 @@ void ToggleReply() => isReplying = !isReplying; + void ToggleQuote() => isQuoting = !isQuoting; + + void ToggleEdit() => isEditing = !isEditing; + + // the server says whether the persona may quote it; without that, a public or unlisted post may be asked + static bool CanQuote(Status status) => status.QuoteApproval?.CurrentUser is { } answer + ? answer is "automatic" or "manual" + : status.Visibility is "public" or "unlisted"; + + async Task Quoted(FeedPost quote) + { + isQuoting = false; + await OnReplied.InvokeAsync(quote); + } + + void Edited(FeedPost edited) + { + isEditing = false; + if (edited?.Status is { } status) + Post.Status = status; + } + async Task Replied(FeedPost reply) { isReplying = false; diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index cc7e206..0449e30 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -33,6 +33,8 @@ namespace decePubClient; [JsonSerializable(typeof(TokenResponse))] [JsonSerializable(typeof(MastodonError))] [JsonSerializable(typeof(PostStatusForm))] +[JsonSerializable(typeof(StatusEditForm))] +[JsonSerializable(typeof(StatusSource))] [JsonSerializable(typeof(Poll))] [JsonSerializable(typeof(PollVoteForm))] [JsonSerializable(typeof(MastodonList))] diff --git a/Models/APIs.cs b/Models/APIs.cs index bfc75fe..1adb720 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -71,6 +71,8 @@ public static class APIs public static string GET_M_AccountLists(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/lists"; public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}"; public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses"; + public static string GET_M_StatusSource(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/source"; + public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}"; public const string GET_FollowedTags = "api/v1/followed_tags"; public const string GET_GroupedNotifications = "api/v2/notifications"; public const string GET_UnreadNotifications = "api/v2/notifications/unread_count"; diff --git a/Models/ComposeForm.cs b/Models/ComposeForm.cs index d3414b1..2c485ed 100644 --- a/Models/ComposeForm.cs +++ b/Models/ComposeForm.cs @@ -37,6 +37,15 @@ public class ComposeForm public double? Latitude { get; set; } public double? Longitude { get; set; } + /// A poll instead of media: two to four choices, open ExpiresIn seconds, one choice or several. + public bool HasPoll { get; set; } + public List PollOptions { get; set; } = ["", ""]; + public int PollExpiresIn { get; set; } = 86400; + public bool PollMultiple { get; set; } + + /// The post this one quotes. + public string QuotedStatusId { get; set; } + /// Whether the post waits for ScheduledAt (a datetime-local value, the reader's own time) instead of going now. public bool IsScheduled { get; set; } public string ScheduledAt { get; set; } diff --git a/Models/Mastodon/PrivaPubApi.cs b/Models/Mastodon/PrivaPubApi.cs index 9f5dd99..7aaa239 100644 --- a/Models/Mastodon/PrivaPubApi.cs +++ b/Models/Mastodon/PrivaPubApi.cs @@ -178,6 +178,32 @@ public class PostStatusForm public string Language { get; set; } /// When the post is to be published (ISO 8601, at least five minutes ahead); null posts it now. public string ScheduledAt { get; set; } + public PollForm Poll { get; set; } + public string QuotedStatusId { get; set; } +} + +public class PollForm +{ + public List Options { get; set; } = []; + public int ExpiresIn { get; set; } + public bool Multiple { get; set; } +} + +/// What an edit sends: the words and their warning; the media stay as they are. +public class StatusEditForm +{ + public string Status { get; set; } + public string SpoilerText { get; set; } + public bool Sensitive { get; set; } + public string Language { get; set; } +} + +/// /api/v1/statuses/:id/source: the author's own text of a post, to edit it. +public class StatusSource +{ + public string Id { get; set; } + public string Text { get; set; } + public string SpoilerText { get; set; } } /// A post waiting for its time (/api/v1/scheduled_statuses). diff --git a/Models/Mastodon/Status.cs b/Models/Mastodon/Status.cs index 3ce85b4..57a7a83 100644 --- a/Models/Mastodon/Status.cs +++ b/Models/Mastodon/Status.cs @@ -45,6 +45,8 @@ public class Status /// The persona's filters this post matches, whatever their context (the card picks those of where it is). public List Filtered { get; set; } = []; public int QuotesCount { get; set; } + /// Who may quote it: CurrentUser is automatic, manual (asked) or denied for the persona. + public QuoteApproval QuoteApproval { get; set; } } public class StatusMention @@ -130,3 +132,10 @@ public class PreviewCard public int Height { get; set; } public string Blurhash { get; set; } } + +public class QuoteApproval +{ + public List Automatic { get; set; } = []; + public List Manual { get; set; } = []; + public string CurrentUser { get; set; } +} diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 3f7c3ef..f8667da 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -1217,4 +1217,55 @@ {0} e {1} ti seguono + + Scelta {0} + + + Aggiungi una scelta + + + Durata del sondaggio + + + Più scelte + + + Sondaggio + + + Modifica il tuo post + + + Dì qualcosa al riguardo + + + Cita + + + 5 minuti + + + 30 minuti + + + 1 ora + + + 6 ore + + + 1 giorno + + + 3 giorni + + + 7 giorni + + + Un sondaggio ha bisogno di almeno due scelte. + + + Salvato. + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index df6b814..98de833 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -1217,4 +1217,55 @@ {0} and {1} followed you + + Choice {0} + + + Add a choice + + + Poll length + + + Several choices + + + Poll + + + Edit your post + + + Say something about it + + + Quote + + + 5 minutes + + + 30 minutes + + + 1 hour + + + 6 hours + + + 1 day + + + 3 days + + + 7 days + + + A poll needs two choices at least. + + + Saved. + diff --git a/Services/StatusService.cs b/Services/StatusService.cs index c2ed72c..5fc2cd9 100644 --- a/Services/StatusService.cs +++ b/Services/StatusService.cs @@ -55,7 +55,9 @@ namespace decePubClient.Services SpoilerText = form.HasContentWarning ? form.SpoilerText : null, Visibility = form.Visibility, Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName, - ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture) + ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture), + Poll = form.HasPoll ? new PollForm { Options = [.. form.PollOptions.Select(o => o.Trim()).Where(o => o.Length > 0)], ExpiresIn = form.PollExpiresIn, Multiple = form.PollMultiple } : null, + QuotedStatusId = form.QuotedStatusId }, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); @@ -66,6 +68,29 @@ namespace decePubClient.Services return new WebResult { Data = FeedPost.Of(status) }; } + /// The persona's own text of a post, to edit it; null when it cannot be read. + public async Task Source(string statusId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_M_StatusSource(statusId), cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync(cancellationToken) : null; + } + + /// Edits the persona's own post: new words and warning, the media kept; Data is the edited Status. + public async Task Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default) + { + using var response = await httpService.Put(APIs.Mastodon.PUT_M_Status(statusId), new StatusEditForm + { + Status = form.Content ?? string.Empty, + SpoilerText = form.HasContentWarning ? form.SpoilerText : null, + Sensitive = form.HasContentWarning, + Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName + }, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + /// The persona's posts waiting for their time, soonest first. public async Task> Scheduled(CancellationToken cancellationToken = default) { diff --git a/tests/e2e/Actions/ComposerTests.cs b/tests/e2e/Actions/ComposerTests.cs new file mode 100644 index 0000000..03549a5 --- /dev/null +++ b/tests/e2e/Actions/ComposerTests.cs @@ -0,0 +1,105 @@ +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Actions +{ + // The composer beyond words: a poll, the persona's own post edited, and a quote, each checked on PrivaPub + public sealed class ComposerTests(Browser browser) + { + static async Task OpenHome(IPage page) + { + await page.GotoAsync($"{Pasture.Site}/"); + await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')"); + await page.GotoAsync($"{Pasture.Site}/"); + await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 }); + } + + static async Task Latest(Pasture.Persona persona) => + (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault(); + + [Fact] + public async Task A_poll_is_published_from_the_composer() + { + var persona = Pasture.Personas().First(); + var text = $"which one {Guid.NewGuid():N}"[..18]; + await using var session = await SignIn.As(browser, persona); + var page = session.Page; + string made = null; + try + { + await Results.Cell("client.action.poll", "privapub", persona.UserName, async () => + { + await OpenHome(page); + await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync(); + await page.Locator("textarea[data-compose]").First.FillAsync(text); + await page.Locator("button[data-poll-toggle]").ClickAsync(); + var choices = page.Locator("[data-poll-editor] input[type=text]"); + await choices.Nth(0).FillAsync("tea"); + await choices.Nth(1).FillAsync("coffee"); + await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).Last.ClickAsync(); + JsonNode latest = null; + Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue().Contains(text) == true, 15), + "the poll was never published"); + made = latest!["id"]!.GetValue(); + Assert.Equal(new[] { "tea", "coffee" }, latest["poll"]!["options"]!.AsArray().Select(o => o!["title"]!.GetValue())); + await session.AssertGuards(); + }, page); + } + finally + { + if (made is not null) + await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete); + } + } + + [Fact] + public async Task The_personas_own_post_is_edited_and_quoted_from_its_card() + { + var persona = Pasture.Personas().First(); + var text = $"first words {Guid.NewGuid():N}"[..20]; + var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!; + var id = post["id"]!.GetValue(); + string quote = null; + await using var session = await SignIn.As(browser, persona); + var page = session.Page; + try + { + await Results.Cell("client.action.edit-quote", "privapub", persona.UserName, async () => + { + await OpenHome(page); + var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue()}']").First; + await card.WaitForAsync(new() { Timeout = 30000 }); + + await card.Locator("footer [title='Other'] > [role=button]").ClickAsync(); + await card.Locator("button[data-edit]").ClickAsync(); + var box = card.Locator("textarea[data-compose]"); + await Assertions.Expect(box).ToHaveValueAsync(text, new() { Timeout = 10000 }); + await box.FillAsync(text + " (edited)"); + await card.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync(); + await Assertions.Expect(card).ToContainTextAsync(text + " (edited)", new() { Timeout = 10000 }); + Assert.Contains("(edited)", (await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"))!["content"]!.GetValue()); + + await card.Locator("footer [title='Other'] > [role=button]").ClickAsync(); + await card.Locator("button[data-quote]").ClickAsync(); + await card.Locator("textarea[data-compose]").FillAsync("quoting myself"); + await card.GetByRole(AriaRole.Button, new() { Name = "Quote" }).Last.ClickAsync(); + JsonNode latest = null; + Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue().Contains("quoting myself") == true, 15), + "the quote was never published"); + quote = latest!["id"]!.GetValue(); + Assert.Equal(id, (latest["quote"]?["quoted_status"]?["id"] ?? latest["quote"]?["quoted_status_id"])?.GetValue()); + await session.AssertGuards(); + }, page); + } + finally + { + if (quote is not null) + await PersonaApi.Call(persona, $"/api/v1/statuses/{quote}", HttpMethod.Delete); + await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete); + } + } + } +}