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, 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 conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
its media (blur). 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 - **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. 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 - **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 @* 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 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. 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"> <EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
@@ -17,10 +19,49 @@
<div class="relative"> <div class="relative">
<DTextarea TValue="string" @bind-Value="form.Content" Immediate="true" <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" 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> <span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
</div> </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) @if (form.Media.Count > 0)
{ {
<div class="flex flex-col gap-2"> <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 justify-between gap-2">
<div class="flex flex-wrap items-center 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")" <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()]]"> 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> <i class="ion-md-warning text-base"></i>
</DButton> </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. *@ @* 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"]"> <label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
@@ -116,14 +157,21 @@
</DSelect> </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"]"> <DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
<i class="ion-md-locate text-base"></i> <i class="ion-md-locate text-base"></i>
</DButton> </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> <DButton Size="DSize.Sm" Active="form.IsScheduled" OnClick="ToggleScheduled" title="@Localizer["Publish later"]" data-schedule>
<i class="ion-md-time text-base"></i> <i class="ion-md-time text-base"></i>
@@ -136,7 +184,7 @@
{ {
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" /> <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> <i class="@(form.IsScheduled ? "ion-md-time" : "ion-md-send")"></i>
</DButton> </DButton>
</div> </div>
@@ -163,6 +211,10 @@
[Parameter] public Status InReplyTo { get; set; } [Parameter] public Status InReplyTo { get; set; }
/// <summary>The community or circle the post is for.</summary> /// <summary>The community or circle the post is for.</summary>
[Parameter] public string GroupId { get; set; } [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> /// <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; } [Parameter] public EventCallback<FeedPost> OnPublished { get; set; }
@@ -173,6 +225,22 @@
int ContentLength => form.Content?.Length ?? 0; 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; int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
long MaximumFileSize => form.MediaType switch long MaximumFileSize => form.MediaType switch
@@ -190,10 +258,18 @@
_ => default _ => default
}; };
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
IsLoading = false;
form.GroupId = GroupId; 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) if (InReplyTo is null)
return; return;
@@ -297,6 +373,25 @@
error = Localizer["Missing content, either message or media"]; error = Localizer["Missing content, either message or media"];
return; 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) if (CascadingState.PersonaId is null)
{ {
error = Localizer["Choose a persona first."]; error = Localizer["Choose a persona first."];
+46 -2
View File
@@ -195,9 +195,10 @@
</div> </div>
<div class="flex gap-2"> <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")" 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> <Trigger>
<i aria-hidden="true" class="ion-md-more text-lg"></i> <i aria-hidden="true" class="ion-md-more text-lg"></i>
</Trigger> </Trigger>
@@ -209,6 +210,13 @@
<span>@Localizer["Open original"]</span> <span>@Localizer["Open original"]</span>
</a> </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) @if (!IsOwn)
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
@@ -222,6 +230,10 @@
} }
else 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"> <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> <i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
<span>@Localizer["Delete"]</span> <span>@Localizer["Delete"]</span>
@@ -242,6 +254,14 @@
{ {
<Composer InReplyTo="status" OnPublished="Replied" /> <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; } [Parameter] public FilterContext? FilterIn { get; set; }
bool isReplying; bool isReplying;
bool isQuoting;
bool isEditing;
bool isActing; bool isActing;
bool isOptionsOpen; bool isOptionsOpen;
bool isSpoilerOpen; bool isSpoilerOpen;
@@ -585,6 +607,28 @@
void ToggleReply() => isReplying = !isReplying; 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) async Task Replied(FeedPost reply)
{ {
isReplying = false; isReplying = false;
+2
View File
@@ -33,6 +33,8 @@ namespace decePubClient;
[JsonSerializable(typeof(TokenResponse))] [JsonSerializable(typeof(TokenResponse))]
[JsonSerializable(typeof(MastodonError))] [JsonSerializable(typeof(MastodonError))]
[JsonSerializable(typeof(PostStatusForm))] [JsonSerializable(typeof(PostStatusForm))]
[JsonSerializable(typeof(StatusEditForm))]
[JsonSerializable(typeof(StatusSource))]
[JsonSerializable(typeof(Poll))] [JsonSerializable(typeof(Poll))]
[JsonSerializable(typeof(PollVoteForm))] [JsonSerializable(typeof(PollVoteForm))]
[JsonSerializable(typeof(MastodonList))] [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_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 static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}";
public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses"; 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_FollowedTags = "api/v1/followed_tags";
public const string GET_GroupedNotifications = "api/v2/notifications"; public const string GET_GroupedNotifications = "api/v2/notifications";
public const string GET_UnreadNotifications = "api/v2/notifications/unread_count"; 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? Latitude { get; set; }
public double? Longitude { 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> /// <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 bool IsScheduled { get; set; }
public string ScheduledAt { get; set; } public string ScheduledAt { get; set; }
+26
View File
@@ -178,6 +178,32 @@ public class PostStatusForm
public string Language { get; set; } 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> /// <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 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> /// <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> /// <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 List<FilterResult> Filtered { get; set; } = [];
public int QuotesCount { 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 public class StatusMention
@@ -130,3 +132,10 @@ public class PreviewCard
public int Height { get; set; } public int Height { get; set; }
public string Blurhash { 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"> <data name="{0} and {1} followed you" xml:space="preserve">
<value>{0} e {1} ti seguono</value> <value>{0} e {1} ti seguono</value>
</data> </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> </root>
+51
View File
@@ -1217,4 +1217,55 @@
<data name="{0} and {1} followed you" xml:space="preserve"> <data name="{0} and {1} followed you" xml:space="preserve">
<value>{0} and {1} followed you</value> <value>{0} and {1} followed you</value>
</data> </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> </root>
+26 -1
View File
@@ -55,7 +55,9 @@ namespace decePubClient.Services
SpoilerText = form.HasContentWarning ? form.SpoilerText : null, SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.Visibility, Visibility = form.Visibility,
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName, 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); }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
@@ -66,6 +68,29 @@ namespace decePubClient.Services
return new WebResult { Data = FeedPost.Of(status) }; 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> /// <summary>The persona's posts waiting for their time, soonest first.</summary>
public async Task<List<ScheduledStatus>> Scheduled(CancellationToken cancellationToken = default) 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);
}
}
}
}