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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 04:42:42 +02:00
1 parent a4d9831691
commit 5c11727f58
12 files changed
+434 -12

No files matched your search

+3
View File
@@ -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
+104 -9
View File
@@ -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). *@
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
<DataAnnotationsValidator />
@@ -17,10 +19,49 @@
<div class="relative">
<DTextarea TValue="string" @bind-Value="form.Content" Immediate="true"
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
placeholder="@(InReplyTo is null ? Localizer["What's happening?"] : Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct])" rows="3" />
placeholder="@Placeholder" rows="3" data-compose />
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
</div>
@if (Quoting is not null)
{
<Content Post="FeedPost.Of(Quoting)" Compact="true" CssContainer="opacity-90" />
}
@if (form.HasPoll)
{
<div class="flex flex-col gap-2 rounded-xl p-3 neo-inset neo-2xs" data-poll-editor>
@for (var i = 0; i < form.PollOptions.Count; i++)
{
var index = i;
<div class="flex items-center gap-2">
<input type="text" class="input input-sm flex-1" maxlength="100" autocomplete="off" placeholder="@Localizer["Choice {0}", index + 1]"
value="@form.PollOptions[index]" @onchange="@((ChangeEventArgs e) => form.PollOptions[index] = e.Value?.ToString())" />
@if (form.PollOptions.Count > 2)
{
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.RemoveAt(index)" title="@Localizer["Remove"]"><i class="ion-md-close"></i></DButton>
}
</div>
}
<div class="flex flex-wrap items-center gap-2">
@if (form.PollOptions.Count < 4)
{
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.Add(string.Empty)"><i class="ion-md-add"></i><span>@Localizer["Add a choice"]</span></DButton>
}
<DSelect TValue="int" Size="DSize.Xs" class="w-auto" @bind-Value="form.PollExpiresIn" aria-label="@Localizer["Poll length"]">
@foreach (var (seconds, label) in PollLengths)
{
<option value="@seconds">@label</option>
}
</DSelect>
<label class="label text-xs">
<input type="checkbox" class="checkbox checkbox-xs" checked="@form.PollMultiple" @onchange="@((ChangeEventArgs e) => form.PollMultiple = e.Value is true)" />
<span>@Localizer["Several choices"]</span>
</label>
</div>
</div>
}
@if (form.Media.Count > 0)
{
<div class="flex flex-col gap-2">
@@ -70,7 +111,7 @@
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
@if (!form.IsLocated && GroupId is null)
@if (!form.IsLocated && GroupId is null && Editing is null)
{
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
@@ -92,7 +133,7 @@
<i class="ion-md-warning text-base"></i>
</DButton>
@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. *@
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
@@ -116,14 +157,21 @@
</DSelect>
}
@if (InReplyTo is null && GroupId is null)
@if (!form.IsLocated && GroupId is null && Editing is null && form.Media.Count == 0)
{
<DButton Size="DSize.Sm" Active="form.HasPoll" OnClick="() => form.HasPoll = !form.HasPoll" title="@Localizer["Poll"]" data-poll-toggle>
<i class="ion-md-stats text-base"></i>
</DButton>
}
@if (InReplyTo is null && GroupId is null && Editing is null && Quoting is null)
{
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
<i class="ion-md-locate text-base"></i>
</DButton>
}
@if (!form.IsLocated && GroupId is null)
@if (!form.IsLocated && GroupId is null && Editing is null)
{
<DButton Size="DSize.Sm" Active="form.IsScheduled" OnClick="ToggleScheduled" title="@Localizer["Publish later"]" data-schedule>
<i class="ion-md-time text-base"></i>
@@ -136,7 +184,7 @@
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<span>@(form.IsScheduled ? Localizer["Schedule"] : Localizer[InReplyTo is null ? "Post" : "Reply"])</span>
<span>@SubmitLabel</span>
<i class="@(form.IsScheduled ? "ion-md-time" : "ion-md-send")"></i>
</DButton>
</div>
@@ -163,6 +211,10 @@
[Parameter] public Status InReplyTo { get; set; }
/// <summary>The community or circle the post is for.</summary>
[Parameter] public string GroupId { get; set; }
/// <summary>The post this one quotes (shown under the words).</summary>
[Parameter] public Status Quoting { get; set; }
/// <summary>The persona's own post being edited: its words come from /source, and saving replaces them.</summary>
[Parameter] public Status Editing { get; set; }
/// <summary>The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API).</summary>
[Parameter] public EventCallback<FeedPost> 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."];
+46 -2
View File
@@ -195,9 +195,10 @@
</div>
<div class="flex gap-2">
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.End" CloseOnContentClick="true" Placement="DDropdownPlacement.Top"
@* opens sideways and downwards: opened upwards, the first card's menu was cut off by the top of the feed *@
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.Start" CloseOnContentClick="true" Placement="DDropdownPlacement.Left"
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
ContentClass="mb-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
ContentClass="mr-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
<Trigger>
<i aria-hidden="true" class="ion-md-more text-lg"></i>
</Trigger>
@@ -209,6 +210,13 @@
<span>@Localizer["Open original"]</span>
</a>
}
@if (CanQuote(status))
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleQuote" data-quote>
<i aria-hidden="true" class="ion-md-quote text-base"></i>
<span>@Localizer["Quote"]</span>
</DButton>
}
@if (!IsOwn)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
@@ -222,6 +230,10 @@
}
else
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit>
<i aria-hidden="true" class="ion-md-create text-base"></i>
<span>@Localizer["Edit"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="Delete">
<i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
<span>@Localizer["Delete"]</span>
@@ -242,6 +254,14 @@
{
<Composer InReplyTo="status" OnPublished="Replied" />
}
@if (isQuoting)
{
<Composer Quoting="status" OnPublished="Quoted" />
}
@if (isEditing)
{
<Composer Editing="status" OnPublished="Edited" />
}
}
}
}
@@ -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;
+2
View File
@@ -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))]
+2
View File
@@ -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";
+9
View File
@@ -37,6 +37,15 @@ public class ComposeForm
public double? Latitude { get; set; }
public double? Longitude { get; set; }
/// <summary>A poll instead of media: two to four choices, open ExpiresIn seconds, one choice or several.</summary>
public bool HasPoll { get; set; }
public List<string> PollOptions { get; set; } = ["", ""];
public int PollExpiresIn { get; set; } = 86400;
public bool PollMultiple { get; set; }
/// <summary>The post this one quotes.</summary>
public string QuotedStatusId { get; set; }
/// <summary>Whether the post waits for ScheduledAt (a datetime-local value, the reader's own time) instead of going now.</summary>
public bool IsScheduled { get; set; }
public string ScheduledAt { get; set; }
+26
View File
@@ -178,6 +178,32 @@ public class PostStatusForm
public string Language { get; set; }
/// <summary>When the post is to be published (ISO 8601, at least five minutes ahead); null posts it now.</summary>
public string ScheduledAt { get; set; }
public PollForm Poll { get; set; }
public string QuotedStatusId { get; set; }
}
public class PollForm
{
public List<string> Options { get; set; } = [];
public int ExpiresIn { get; set; }
public bool Multiple { get; set; }
}
/// <summary>What an edit sends: the words and their warning; the media stay as they are.</summary>
public class StatusEditForm
{
public string Status { get; set; }
public string SpoilerText { get; set; }
public bool Sensitive { get; set; }
public string Language { get; set; }
}
/// <summary>/api/v1/statuses/:id/source: the author's own text of a post, to edit it.</summary>
public class StatusSource
{
public string Id { get; set; }
public string Text { get; set; }
public string SpoilerText { get; set; }
}
/// <summary>A post waiting for its time (/api/v1/scheduled_statuses).</summary>
+9
View File
@@ -45,6 +45,8 @@ public class Status
/// <summary>The persona's filters this post matches, whatever their context (the card picks those of where it is).</summary>
public List<FilterResult> Filtered { get; set; } = [];
public int QuotesCount { get; set; }
/// <summary>Who may quote it: CurrentUser is automatic, manual (asked) or denied for the persona.</summary>
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<string> Automatic { get; set; } = [];
public List<string> Manual { get; set; } = [];
public string CurrentUser { get; set; }
}
+51
View File
@@ -1217,4 +1217,55 @@
<data name="{0} and {1} followed you" xml:space="preserve">
<value>{0} e {1} ti seguono</value>
</data>
<data name="Choice {0}" xml:space="preserve">
<value>Scelta {0}</value>
</data>
<data name="Add a choice" xml:space="preserve">
<value>Aggiungi una scelta</value>
</data>
<data name="Poll length" xml:space="preserve">
<value>Durata del sondaggio</value>
</data>
<data name="Several choices" xml:space="preserve">
<value>Più scelte</value>
</data>
<data name="Poll" xml:space="preserve">
<value>Sondaggio</value>
</data>
<data name="Edit your post" xml:space="preserve">
<value>Modifica il tuo post</value>
</data>
<data name="Say something about it" xml:space="preserve">
<value>Dì qualcosa al riguardo</value>
</data>
<data name="Quote" xml:space="preserve">
<value>Cita</value>
</data>
<data name="5 minutes" xml:space="preserve">
<value>5 minuti</value>
</data>
<data name="30 minutes" xml:space="preserve">
<value>30 minuti</value>
</data>
<data name="1 hour" xml:space="preserve">
<value>1 ora</value>
</data>
<data name="6 hours" xml:space="preserve">
<value>6 ore</value>
</data>
<data name="1 day" xml:space="preserve">
<value>1 giorno</value>
</data>
<data name="3 days" xml:space="preserve">
<value>3 giorni</value>
</data>
<data name="7 days" xml:space="preserve">
<value>7 giorni</value>
</data>
<data name="A poll needs two choices at least." xml:space="preserve">
<value>Un sondaggio ha bisogno di almeno due scelte.</value>
</data>
<data name="Saved." xml:space="preserve">
<value>Salvato.</value>
</data>
</root>
+51
View File
@@ -1217,4 +1217,55 @@
<data name="{0} and {1} followed you" xml:space="preserve">
<value>{0} and {1} followed you</value>
</data>
<data name="Choice {0}" xml:space="preserve">
<value>Choice {0}</value>
</data>
<data name="Add a choice" xml:space="preserve">
<value>Add a choice</value>
</data>
<data name="Poll length" xml:space="preserve">
<value>Poll length</value>
</data>
<data name="Several choices" xml:space="preserve">
<value>Several choices</value>
</data>
<data name="Poll" xml:space="preserve">
<value>Poll</value>
</data>
<data name="Edit your post" xml:space="preserve">
<value>Edit your post</value>
</data>
<data name="Say something about it" xml:space="preserve">
<value>Say something about it</value>
</data>
<data name="Quote" xml:space="preserve">
<value>Quote</value>
</data>
<data name="5 minutes" xml:space="preserve">
<value>5 minutes</value>
</data>
<data name="30 minutes" xml:space="preserve">
<value>30 minutes</value>
</data>
<data name="1 hour" xml:space="preserve">
<value>1 hour</value>
</data>
<data name="6 hours" xml:space="preserve">
<value>6 hours</value>
</data>
<data name="1 day" xml:space="preserve">
<value>1 day</value>
</data>
<data name="3 days" xml:space="preserve">
<value>3 days</value>
</data>
<data name="7 days" xml:space="preserve">
<value>7 days</value>
</data>
<data name="A poll needs two choices at least." xml:space="preserve">
<value>A poll needs two choices at least.</value>
</data>
<data name="Saved." xml:space="preserve">
<value>Saved.</value>
</data>
</root>
+26 -1
View File
@@ -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) };
}
/// <summary>The persona's own text of a post, to edit it; null when it cannot be read.</summary>
public async Task<StatusSource> 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<StatusSource>(cancellationToken) : null;
}
/// <summary>Edits the persona's own post: new words and warning, the media kept; Data is the edited Status.</summary>
public async Task<WebResult> 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<Status>(cancellationToken) };
}
/// <summary>The persona's posts waiting for their time, soonest first.</summary>
public async Task<List<ScheduledStatus>> Scheduled(CancellationToken cancellationToken = default)
{
+105
View File
@@ -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<JsonNode> 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<string>().Contains(text) == true, 15),
"the poll was never published");
made = latest!["id"]!.GetValue<string>();
Assert.Equal(new[] { "tea", "coffee" }, latest["poll"]!["options"]!.AsArray().Select(o => o!["title"]!.GetValue<string>()));
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>();
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<string>()}']").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<string>());
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<string>().Contains("quoting myself") == true, 15),
"the quote was never published");
quote = latest!["id"]!.GetValue<string>();
Assert.Equal(id, (latest["quote"]?["quoted_status"]?["id"] ?? latest["quote"]?["quoted_status_id"])?.GetValue<string>());
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);
}
}
}
}