+
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+@code {
+ [Inject] OverlayService Overlays { get; set; }
+ [Inject] NavigationManager Navigation { get; set; }
+ [Inject] AppStatusService Status { get; set; }
+
+ IDisposable changing;
+
+ protected override void OnInitialized() => changing = Navigation.RegisterLocationChangingHandler(OnLocationChanging);
+
+ async ValueTask OnLocationChanging(LocationChangingContext context)
+ {
+ var back = !context.IsNavigationIntercepted && Status.TookBack();
+ if (!Overlays.HasOpen)
+ return;
+ if (back)
+ {
+ context.PreventNavigation();
+ await Overlays.CloseTop(context.CancellationToken);
+ return;
+ }
+ await Overlays.CloseAll(context.CancellationToken);
+ }
+
+ public void Dispose() => changing?.Dispose();
+}
diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor
index 47292e1..4fcf721 100644
--- a/Shared/NavMenu.razor
+++ b/Shared/NavMenu.razor
@@ -141,7 +141,27 @@
@code {
[Inject] ConfirmService Confirm { get; set; }
+ [Inject] OverlayService Overlays { get; set; }
+
bool isMenuOpen;
+ IDisposable overlay;
+
+ // open, the Back button closes it
+ void SetMenu(bool open)
+ {
+ isMenuOpen = open;
+ if (open && overlay is null)
+ overlay = Overlays.Open(() => InvokeAsync(() =>
+ {
+ SetMenu(false);
+ StateHasChanged();
+ }));
+ else if (!open && overlay is not null)
+ {
+ overlay.Dispose();
+ overlay = null;
+ }
+ }
bool isPersonaMenuOpen;
string PersonaBackground => CascadingState.Persona?.Header is { Length: > 0 } header && !header.Contains("/missing-")
@@ -162,37 +182,38 @@
{
if (!isMenuOpen)
return;
- isMenuOpen = false;
+ SetMenu(false);
InvokeAsync(StateHasChanged);
}
- void CloseMenu() => isMenuOpen = false;
+ void CloseMenu() => SetMenu(false);
void OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
- isMenuOpen = false;
+ SetMenu(false);
}
public override async ValueTask DisposeAsync()
{
Navigation.LocationChanged -= OnLocationChanged;
+ overlay?.Dispose();
await base.DisposeAsync();
}
async Task SwitchPersona(string personaId)
{
- isMenuOpen = false;
+ SetMenu(false);
var result = await CascadingState.SwitchPersona(personaId);
if (result.IsInvalid)
Toast.AddResponseError(result);
}
- private void ToggleNavMenu() => isMenuOpen = !isMenuOpen;
+ private void ToggleNavMenu() => SetMenu(!isMenuOpen);
async Task Logout()
{
- isMenuOpen = false;
+ SetMenu(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");
diff --git a/tests/e2e/Mobile/FlowTests.cs b/tests/e2e/Mobile/FlowTests.cs
index 9da1b2b..6da7411 100644
--- a/tests/e2e/Mobile/FlowTests.cs
+++ b/tests/e2e/Mobile/FlowTests.cs
@@ -310,6 +310,51 @@ namespace decePubClient.E2E.Mobile
});
}
+ // the Back button closes what is open, one at a time, and stays on the page
+ [Fact]
+ public async Task Back_closes_what_is_open()
+ {
+ await using var session = await SignIn.As(browser, Persona(), Phone);
+ await Flow(session, "back", async page =>
+ {
+ var home = page.Url;
+ // a page to go back to, so a Back that isn't held would leave the feed
+ await page.Locator("[data-nav-toggle]").TapAsync();
+ await page.Locator("[data-nav-panel] a[href='explore']").TapAsync();
+ await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore");
+ await page.Locator("[data-nav-toggle]").TapAsync();
+ await page.Locator("[data-nav-panel] a[href='/']").First.TapAsync();
+ await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/");
+ await page.Locator("#feed-scroll article[data-uri]").First.WaitForAsync();
+
+ async Task BackCloses(ILocator open, string what)
+ {
+ await Assertions.Expect(open).ToBeVisibleAsync();
+ await page.GoBackAsync();
+ await Assertions.Expect(open).ToBeHiddenAsync(new() { Timeout = 5000 });
+ Assert.True(new Uri(page.Url).AbsolutePath == "/", $"Back after the {what} left the feed for {page.Url}");
+ }
+
+ await page.Locator("[data-nav-toggle]").TapAsync();
+ await BackCloses(page.Locator("[data-nav-panel]"), "menu");
+
+ var card = Card(page, "footer [title='Other']");
+ await card.Locator("footer [title='Other'] > [role=button]").TapAsync();
+ await BackCloses(card.Locator(".dropdown-open > [data-sheet]"), "More sheet");
+
+ await page.Locator("[data-compose-open]").TapAsync();
+ await BackCloses(page.Locator("[data-sheet]:has(textarea[data-compose])"), "composer");
+
+ await page.Locator("button[title='Globe']").TapAsync();
+ await page.Locator("[data-globe-expand]").TapAsync();
+ await BackCloses(page.Locator("aside[role=dialog]"), "full-screen globe");
+
+ // with nothing open, Back leaves the feed as before
+ await page.GoBackAsync();
+ await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore", new() { Timeout = 10000 });
+ });
+ }
+
static async Task
Latest(Pasture.Persona persona) =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
}
diff --git a/wwwroot/main.js b/wwwroot/main.js
index b09025d..6b5dfbb 100644
--- a/wwwroot/main.js
+++ b/wwwroot/main.js
@@ -140,6 +140,16 @@ document.addEventListener("click", event => {
window.logFromJs(e.message, "openThread")
}
})
+// The Back button (a popstate) as opposed to the app's own navigation: Blazor's location-changing handlers can't tell
+// them apart, and MainLayout lets Back close an open overlay but lets the app's navigation through. Registered for the
+// capture phase, so it runs before Blazor's own listener.
+let backAt = 0
+window.addEventListener("popstate", () => backAt = Date.now(), { capture: true })
+window.tookBack = () => {
+ const back = Date.now() - backAt < 1000
+ backAt = 0
+ return back
+}
window.canHover = () => {
try {
return window.matchMedia("(hover: hover)").matches