From 1c9773bab0b00161d72c29c07d93cd66cf830614 Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 23:28:22 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- Components/ConfirmHost.razor | 40 ++++++++++++++++++++++++++ Components/Content.razor | 10 +++++++ Pages/Groups.razor | 3 ++ Pages/Profile.razor | 6 ++++ Pages/Scheduled.razor | 3 ++ Program.cs | 1 + Resources/AllStrings.it.resx | 27 +++++++++++++++++ Resources/AllStrings.resx | 27 +++++++++++++++++ Services/ConfirmService.cs | 40 ++++++++++++++++++++++++++ Shared/MainLayout.razor | 3 +- Shared/NavMenu.razor | 14 +++++++-- tests/e2e/Actions/ActionMatrixTests.cs | 1 + tests/e2e/Actions/DiscoveryTests.cs | 1 + tests/e2e/Actions/WallTests.cs | 1 + tests/e2e/Mobile/FlowTests.cs | 37 ++++++++++++++++++++++++ 15 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 Components/ConfirmHost.razor create mode 100644 Services/ConfirmService.cs diff --git a/Components/ConfirmHost.razor b/Components/ConfirmHost.razor new file mode 100644 index 0000000..4ac4989 --- /dev/null +++ b/Components/ConfirmHost.razor @@ -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. *@ + + @Confirm.Heading + + @if (Confirm.Text is { Length: > 0 } text) + { +

@text

+ } +
+ + @Localizer["Cancel"] + + @Confirm.ConfirmText + + +
+ +@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; +} diff --git a/Components/Content.razor b/Components/Content.razor index 7a3fcbc..ddca400 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -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; diff --git a/Pages/Groups.razor b/Pages/Groups.razor index cf85ab0..18acac6 100644 --- a/Pages/Groups.razor +++ b/Pages/Groups.razor @@ -187,6 +187,7 @@ static readonly string[] PostingPolicies = ["followers", "anyone", "moderators"]; [Inject] GroupService GroupService { get; set; } + [Inject] ConfirmService Confirm { get; set; } List groups = []; List 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; diff --git a/Pages/Profile.razor b/Pages/Profile.razor index ae350aa..1a98254 100644 --- a/Pages/Profile.razor +++ b/Pages/Profile.razor @@ -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; diff --git a/Pages/Scheduled.razor b/Pages/Scheduled.razor index a1a31d7..f8ef0fa 100644 --- a/Pages/Scheduled.razor +++ b/Pages/Scheduled.razor @@ -62,6 +62,7 @@ @code { [Inject] StatusService StatusService { get; set; } + [Inject] ConfirmService Confirm { get; set; } List scheduled = []; readonly Dictionary 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; diff --git a/Program.cs b/Program.cs index dfd3bba..533799d 100644 --- a/Program.cs +++ b/Program.cs @@ -50,6 +50,7 @@ builder.Services.AddOptions() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 9e6c511..c15e576 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -1826,4 +1826,31 @@ Apri il globo a schermo intero + + Eliminare questo post? + + + Sparisce da qui, e ai server che lo hanno viene chiesto di eliminarlo. + + + Togliere questo post dalla tua bacheca? + + + Bloccare {0}? + + + Silenziare {0}? + + + Lasciare {0}? + + + Non pubblicare mai questo post? + + + Uscire? + + + Tutte le persone di questo accesso escono da questo dispositivo. + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index b4b8258..4ce7871 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -1826,4 +1826,31 @@ Open the globe full screen + + Delete this post? + + + It goes from here, and the servers that have it are asked to delete it too. + + + Take this post off your wall? + + + Block {0}? + + + Mute {0}? + + + Leave {0}? + + + Never publish this post? + + + Log out? + + + Every persona of this login is signed out on this device. + diff --git a/Services/ConfirmService.cs b/Services/ConfirmService.cs new file mode 100644 index 0000000..2de177d --- /dev/null +++ b/Services/ConfirmService.cs @@ -0,0 +1,40 @@ +namespace decePubClient.Services +{ + /// + /// 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. + /// + public class ConfirmService + { + TaskCompletionSource 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; } + /// The confirm button shows as a danger (it deletes or cuts something off). + public bool Danger { get; private set; } + + public async Task Ask(string heading, string text, string confirmText, bool danger, CancellationToken cancellationToken = default) + { + pending?.TrySetResult(false); + var asking = new TaskCompletionSource(); + 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); + } +} diff --git a/Shared/MainLayout.razor b/Shared/MainLayout.razor index 93bc0a3..2e73370 100644 --- a/Shared/MainLayout.razor +++ b/Shared/MainLayout.razor @@ -11,4 +11,5 @@ - \ No newline at end of file + + \ No newline at end of file diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor index 635cf0c..47292e1 100644 --- a/Shared/NavMenu.razor +++ b/Shared/NavMenu.razor @@ -89,10 +89,10 @@ @if (CascadingState.IsSignedIn) { - + @Localizer["Logout"] - + } else { @@ -139,6 +139,8 @@ @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; diff --git a/tests/e2e/Actions/ActionMatrixTests.cs b/tests/e2e/Actions/ActionMatrixTests.cs index e4fb67c..316405e 100644 --- a/tests/e2e/Actions/ActionMatrixTests.cs +++ b/tests/e2e/Actions/ActionMatrixTests.cs @@ -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]!; diff --git a/tests/e2e/Actions/DiscoveryTests.cs b/tests/e2e/Actions/DiscoveryTests.cs index 0490323..ff99317 100644 --- a/tests/e2e/Actions/DiscoveryTests.cs +++ b/tests/e2e/Actions/DiscoveryTests.cs @@ -42,6 +42,7 @@ namespace decePubClient.E2E.Actions (await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"))?["scheduled_at"]?.GetValue().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(); diff --git a/tests/e2e/Actions/WallTests.cs b/tests/e2e/Actions/WallTests.cs index 5240522..9407fa4 100644 --- a/tests/e2e/Actions/WallTests.cs +++ b/tests/e2e/Actions/WallTests.cs @@ -83,6 +83,7 @@ namespace decePubClient.E2E.Actions await Assertions.Expect(card.Locator("[data-wall]")).ToContainTextAsync(me["display_name"]!.GetValue() is { Length: > 0 } name ? name : me["username"]!.GetValue()); 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()}") is null, 15), "the post is still on PrivaPub"); Assert.True(await Pasture.Eventually(async () => diff --git a/tests/e2e/Mobile/FlowTests.cs b/tests/e2e/Mobile/FlowTests.cs index 2491520..f452a9b 100644 --- a/tests/e2e/Mobile/FlowTests.cs +++ b/tests/e2e/Mobile/FlowTests.cs @@ -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(); + 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 Latest(Pasture.Persona persona) => (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault(); }