What can't be undone asks first

Deleting a post, taking it off the persona's wall, muting or blocking an account (from a card or a profile), leaving
a group, dropping a scheduled post and signing out acted at the first tap, a few pixels from their neighbours. They now
ask first: new ConfirmService (Ask returns whether the reader confirmed, as PublicNoticeService does) and ConfirmHost in
MainLayout, a sheet from the bottom on a phone and a dialog from md, with Cancel and the action named, in red when it
deletes or cuts off. Logout is a button that asks, then goes to /logout as before. The reversible ones (unmute,
unblock, removing a conversation from the list, a picture from the composer) don't ask; the inline confirms of lists,
filters and the login's deletion stay. The desktop tests that delete a reply, take a post off a wall or drop a
scheduled post confirm; a phone flow deletes a post after cancelling once.

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-06 23:28:22 +02:00
1 parent 931b7fcff1
commit 1c9773bab0
15 files changed
+210 -2

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
@inherits LocalizableComponentBase
@implements IDisposable
@* The question ConfirmService asks before what can't be undone, in MainLayout: a sheet from the bottom on a phone, a
dialog in the middle from md. Closing it in any way answers no. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Confirm.IsAsking" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-confirm>
<Heading>@Confirm.Heading</Heading>
<ChildContent>
@if (Confirm.Text is { Length: > 0 } text)
{
<p class="text-sm">@text</p>
}
</ChildContent>
<Actions>
<DButton class="max-md:flex-1" OnClick="() => Confirm.Answer(false)" data-confirm-no>@Localizer["Cancel"]</DButton>
<DButton class="max-md:flex-1" Color="@(Confirm.Danger ? DColor.Error : DColor.Primary)" OnClick="() => Confirm.Answer(true)" data-confirm-yes>
@Confirm.ConfirmText
</DButton>
</Actions>
</DModal>
@code {
[Inject] ConfirmService Confirm { get; set; }
protected override void OnInitialized()
{
Confirm.Changed += Refresh;
IsLoading = false;
}
void Refresh() => InvokeAsync(StateHasChanged);
void Closed(bool open)
{
if (!open)
Confirm.Answer(false);
}
public void Dispose() => Confirm.Changed -= Refresh;
}
+10
View File
@@ -477,6 +477,7 @@
@code {
[Inject] StatusService StatusService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] TimelineService TimelineService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ProvenanceService ProvenanceService { get; set; }
@@ -913,6 +914,9 @@
async Task Delete()
{
if (!await Confirm.Ask(Localizer["Delete this post?"], Localizer["It goes from here, and the servers that have it are asked to delete it too."],
Localizer["Delete"], true))
return;
isActing = true;
var result = await StatusService.Delete(Status.Id);
isActing = false;
@@ -932,6 +936,8 @@
async Task TakeOffWall()
{
if (!await Confirm.Ask(Localizer["Take this post off your wall?"], null, Localizer["Take off my wall"], true))
return;
isActing = true;
var result = await StatusService.Delete(Status.Id);
isActing = false;
@@ -947,6 +953,10 @@
async Task AccountAction(string action)
{
var name = Status.Account?.Name ?? Status.Account?.Acct;
if (!await Confirm.Ask(action == "block" ? Localizer["Block {0}?", name] : Localizer["Mute {0}?", name], null,
Localizer[action == "block" ? "Block" : "Mute"], action == "block"))
return;
isActing = true;
var result = await StatusService.AccountAction(Status.Account.Id, action);
isActing = false;
+3
View File
@@ -187,6 +187,7 @@
static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"];
[Inject] GroupService GroupService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
List<ViewGroup> groups = [];
List<ViewGroupMember> members = [];
@@ -281,6 +282,8 @@
async Task Leave(ViewGroup group)
{
if (!await Confirm.Ask(Localizer["Leave {0}?", group.Name ?? group.UserName], null, Localizer["Leave"], true))
return;
isSaving = true;
var result = await GroupService.Leave(CascadingState.PersonaId, group.Id);
isSaving = false;
+6
View File
@@ -191,6 +191,7 @@
@code {
[Inject] AccountService AccountService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] ListService ListService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ILocalStorageService LocalStorage { get; set; }
@@ -329,6 +330,11 @@
async Task Do(AccountAction action)
{
var name = account.Name ?? account.Acct;
if (action is AccountAction.Block or AccountAction.Mute
&& !await Confirm.Ask(action == AccountAction.Block ? Localizer["Block {0}?", name] : Localizer["Mute {0}?", name], null,
Localizer[action == AccountAction.Block ? "Block" : "Mute"], action == AccountAction.Block))
return;
isActing = true;
var result = await AccountService.Act(account.Id, action);
isActing = false;
+3
View File
@@ -62,6 +62,7 @@
@code {
[Inject] StatusService StatusService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
List<ScheduledStatus> scheduled = [];
readonly Dictionary<string, string> moves = [];
@@ -129,6 +130,8 @@
async Task Drop(ScheduledStatus item)
{
if (!await Confirm.Ask(Localizer["Never publish this post?"], null, Localizer["Never publish it"], true))
return;
isSaving = true;
var result = await StatusService.Unschedule(item.Id);
isSaving = false;
+1
View File
@@ -50,6 +50,7 @@ builder.Services.AddOptions()
.AddScoped<DiscoveryService>()
.AddScoped<StreamingService>()
.AddScoped<PublicNoticeService>()
.AddScoped<ConfirmService>()
.AddScoped<MediaService>()
.AddScoped<GroupService>()
.AddScoped<InstanceService>()
+27
View File
@@ -1826,4 +1826,31 @@
<data name="Open the globe full screen" xml:space="preserve">
<value>Apri il globo a schermo intero</value>
</data>
<data name="Delete this post?" xml:space="preserve">
<value>Eliminare questo post?</value>
</data>
<data name="It goes from here, and the servers that have it are asked to delete it too." xml:space="preserve">
<value>Sparisce da qui, e ai server che lo hanno viene chiesto di eliminarlo.</value>
</data>
<data name="Take this post off your wall?" xml:space="preserve">
<value>Togliere questo post dalla tua bacheca?</value>
</data>
<data name="Block {0}?" xml:space="preserve">
<value>Bloccare {0}?</value>
</data>
<data name="Mute {0}?" xml:space="preserve">
<value>Silenziare {0}?</value>
</data>
<data name="Leave {0}?" xml:space="preserve">
<value>Lasciare {0}?</value>
</data>
<data name="Never publish this post?" xml:space="preserve">
<value>Non pubblicare mai questo post?</value>
</data>
<data name="Log out?" xml:space="preserve">
<value>Uscire?</value>
</data>
<data name="Every persona of this login is signed out on this device." xml:space="preserve">
<value>Tutte le persone di questo accesso escono da questo dispositivo.</value>
</data>
</root>
+27
View File
@@ -1826,4 +1826,31 @@
<data name="Open the globe full screen" xml:space="preserve">
<value>Open the globe full screen</value>
</data>
<data name="Delete this post?" xml:space="preserve">
<value>Delete this post?</value>
</data>
<data name="It goes from here, and the servers that have it are asked to delete it too." xml:space="preserve">
<value>It goes from here, and the servers that have it are asked to delete it too.</value>
</data>
<data name="Take this post off your wall?" xml:space="preserve">
<value>Take this post off your wall?</value>
</data>
<data name="Block {0}?" xml:space="preserve">
<value>Block {0}?</value>
</data>
<data name="Mute {0}?" xml:space="preserve">
<value>Mute {0}?</value>
</data>
<data name="Leave {0}?" xml:space="preserve">
<value>Leave {0}?</value>
</data>
<data name="Never publish this post?" xml:space="preserve">
<value>Never publish this post?</value>
</data>
<data name="Log out?" xml:space="preserve">
<value>Log out?</value>
</data>
<data name="Every persona of this login is signed out on this device." xml:space="preserve">
<value>Every persona of this login is signed out on this device.</value>
</data>
</root>
+40
View File
@@ -0,0 +1,40 @@
namespace decePubClient.Services
{
/// <summary>
/// Asks before what can't be undone (deleting a post, blocking, leaving a group, signing out): Ask shows the question
/// in ConfirmHost (MainLayout) and returns whether the reader confirmed it. One question at a time; a second one asked
/// while the first is open answers the first no.
/// </summary>
public class ConfirmService
{
TaskCompletionSource<bool> pending;
public event Action Changed;
public bool IsAsking => pending is not null;
public string Heading { get; private set; }
public string Text { get; private set; }
public string ConfirmText { get; private set; }
/// <summary>The confirm button shows as a danger (it deletes or cuts something off).</summary>
public bool Danger { get; private set; }
public async Task<bool> Ask(string heading, string text, string confirmText, bool danger, CancellationToken cancellationToken = default)
{
pending?.TrySetResult(false);
var asking = new TaskCompletionSource<bool>();
pending = asking;
(Heading, Text, ConfirmText, Danger) = (heading, text, confirmText, danger);
Changed?.Invoke();
await using var cancel = cancellationToken.Register(() => asking.TrySetResult(false));
var confirmed = await asking.Task;
if (pending == asking)
{
pending = null;
Changed?.Invoke();
}
return confirmed;
}
public void Answer(bool confirmed) => pending?.TrySetResult(confirmed);
}
}
+1
View File
@@ -12,3 +12,4 @@
<ToastHost />
<PublicNoticeHost />
<ConfirmHost />
+12 -2
View File
@@ -89,10 +89,10 @@
</AuthorizeView>
@if (CascadingState.IsSignedIn)
{
<NavLink ActiveClass="btn-active" class="btn justify-start" href="logout">
<DButton class="justify-start" OnClick="Logout" data-logout>
<i class="ion-md-log-out text-error"></i>
<span>@Localizer["Logout"]</span>
</NavLink>
</DButton>
}
else
{
@@ -139,6 +139,8 @@
</nav>
@code {
[Inject] ConfirmService Confirm { get; set; }
bool isMenuOpen;
bool isPersonaMenuOpen;
@@ -188,6 +190,14 @@
private void ToggleNavMenu() => isMenuOpen = !isMenuOpen;
async Task Logout()
{
isMenuOpen = false;
if (!await Confirm.Ask(Localizer["Log out?"], Localizer["Every persona of this login is signed out on this device."], Localizer["Logout"], true))
return;
Navigation.NavigateTo("logout");
}
protected async Task ResetToOriginalColour()
{
var isDark = IsDark;
+1
View File
@@ -148,6 +148,7 @@ namespace decePubClient.E2E.Actions
await reply.WaitForAsync(new() { Timeout = 15000 });
await reply.Locator("footer [title='Other']").First.ClickAsync();
await reply.GetByRole(AriaRole.Button).Filter(new() { HasText = "Delete" }).First.ClickAsync();
await session.Page.Locator("[data-confirm-yes]").ClickAsync();
Assert.True(await Pasture.Eventually(async () =>
{
var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
+1
View File
@@ -42,6 +42,7 @@ namespace decePubClient.E2E.Actions
(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"))?["scheduled_at"]?.GetValue<DateTime>().ToUniversalTime()
is { } at && Math.Abs((at - later.ToUniversalTime()).TotalMinutes) < 2, 15), "PrivaPub never moved the post");
await item.GetByRole(AriaRole.Button, new() { Name = "Never publish it" }).ClickAsync();
await page.Locator("[data-confirm-yes]").ClickAsync();
await Assertions.Expect(item).ToHaveCountAsync(0, new() { Timeout = 15000 });
Assert.Null(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"));
await session.AssertGuards();
+1
View File
@@ -83,6 +83,7 @@ namespace decePubClient.E2E.Actions
await Assertions.Expect(card.Locator("[data-wall]")).ToContainTextAsync(me["display_name"]!.GetValue<string>() is { Length: > 0 } name ? name : me["username"]!.GetValue<string>());
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
await card.Locator("button[data-take-off-wall]").ClickAsync();
await session.Page.Locator("[data-confirm-yes]").ClickAsync();
Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{status["id"]!.GetValue<string>()}") is null, 15),
"the post is still on PrivaPub");
Assert.True(await Pasture.Eventually(async () =>
+37
View File
@@ -225,6 +225,43 @@ namespace decePubClient.E2E.Mobile
}, page);
}
[Fact]
public async Task Deleting_a_post_asks_first()
{
var persona = Persona();
var text = $"to be deleted {Guid.NewGuid():N}"[..26];
var made = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
var id = made["id"]!.GetValue<string>();
await using var session = await SignIn.As(browser, persona, Phone);
try
{
await Flow(session, "delete", async page =>
{
var card = page.Locator($"#feed-scroll article[data-uri='{made["uri"]}']").First;
await card.WaitForAsync(new() { Timeout = 30000 });
var more = card.Locator("footer [title='Other'] > [role=button]");
await more.TapAsync();
await card.Locator("[data-sheet] button", new() { HasText = "Delete" }).TapAsync();
var confirm = page.Locator("[data-confirm].modal-open");
await Assertions.Expect(confirm).ToBeVisibleAsync();
await AssertWhole(page, "[data-confirm] .modal-box");
await page.Locator("[data-confirm-no]").TapAsync();
await Assertions.Expect(confirm).ToHaveCountAsync(0);
Assert.NotNull(await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"));
await more.TapAsync();
await card.Locator("[data-sheet] button", new() { HasText = "Delete" }).TapAsync();
await page.Locator("[data-confirm-yes]").TapAsync();
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 15000 });
Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{id}") is null, 15), "the post is still there");
});
}
finally
{
await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
}
}
static async Task<JsonNode> Latest(Pasture.Persona persona) =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
}