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:
1 parent
a4d9831691
commit
5c11727f58
12 files changed
+434
-12
No files matched your search
@@ -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
@@ -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."];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))]
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user