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
+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
|
||||
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."];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user