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();
}