From 5522fe4fbbdbbc937182a4221ae450f73f5bb24d Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 23:44:22 +0200 Subject: [PATCH] Pictures open in the app A tap on a picture opened its file in a new tab, leaving the app (and, on a phone, its place in the feed). It now opens MediaViewer: the whole screen on a phone, a large dialog from md, the picture whole with its alt text under it, the post's other pictures by the previous and next buttons or the arrow keys, and "Open original" for whoever wants the file. Phone flow: a picture of a town post opens the viewer over the whole screen, which closes from its button. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- Components/Content.razor | 15 ++++++- Components/MediaViewer.razor | 75 +++++++++++++++++++++++++++++++++++ Resources/AllStrings.it.resx | 12 ++++++ Resources/AllStrings.resx | 12 ++++++ tests/e2e/Mobile/FlowTests.cs | 33 +++++++++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 Components/MediaViewer.razor diff --git a/Components/Content.razor b/Components/Content.razor index 7e93bd4..d1df3d2 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -525,6 +525,7 @@ bool isReplying; bool isDetailsOpen; + MediaAttachment viewingPicture; bool isQuoting; bool isEditing; bool isActing; @@ -626,6 +627,11 @@ } + @if (viewingPicture is { } picture) + { + + } + @if (status.Card is { } card && status.MediaAttachments.Count == 0) { @@ -762,10 +768,11 @@ RenderFragment Media(MediaAttachment media, bool several) => media.Type switch { - "image" => @ + "image" => @, "gifv" => @, "video" => @, @@ -1036,6 +1043,10 @@ await OnReplied.InvokeAsync(reply); } + static List Pictures(Status status) => [.. status.MediaAttachments.Where(m => m.Type == "image")]; + + void OpenPicture(MediaAttachment media) => viewingPicture = media; + async Task OpenDetails() { isDetailsOpen = true; diff --git a/Components/MediaViewer.razor b/Components/MediaViewer.razor new file mode 100644 index 0000000..5a8b2d2 --- /dev/null +++ b/Components/MediaViewer.razor @@ -0,0 +1,75 @@ +@inherits LocalizableComponentBase + +@* A post's pictures, one at a time, in the app (a picture used to open its file in a new tab, leaving it): the whole + screen on a phone, a large dialog from md, with its alt text, the previous and next one (buttons, or the arrow + keys), and the original in a new tab for whoever wants the file. *@ + + + @Localizer["Picture {0} of {1}", index + 1, Pictures.Count] + + +
+
+ @Current.Description + @if (Current.Description is { Length: > 0 } description) + { +
@description
+ } +
+
+ @if (Pictures.Count > 1) + { + + + + } + + + @Localizer["Open original"] + + @if (Pictures.Count > 1) + { + + + + } +
+
+
+
+ +@code { + /// The post's pictures, in its order. + [Parameter, EditorRequired] public List Pictures { get; set; } = []; + /// The one opened. + [Parameter] public int Start { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + + int index; + + MediaAttachment Current => Pictures[Math.Clamp(index, 0, Pictures.Count - 1)]; + + protected override void OnInitialized() + { + index = Math.Clamp(Start, 0, Math.Max(0, Pictures.Count - 1)); + IsLoading = false; + } + + void Previous() => index = Math.Max(0, index - 1); + + void Next() => index = Math.Min(Pictures.Count - 1, index + 1); + + void OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "ArrowLeft") + Previous(); + else if (e.Key == "ArrowRight") + Next(); + } + + Task Closed(bool open) => open ? Task.CompletedTask : OnClose.InvokeAsync(); +} diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 0137395..2f0decd 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -1859,4 +1859,16 @@ Dettagli + + Immagine + + + Immagine precedente + + + Immagine successiva + + + Immagine {0} di {1} + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index e1a785f..34e2589 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -1859,4 +1859,16 @@ Details + + Picture + + + Previous picture + + + Next picture + + + Picture {0} of {1} + diff --git a/tests/e2e/Mobile/FlowTests.cs b/tests/e2e/Mobile/FlowTests.cs index 845b524..9da1b2b 100644 --- a/tests/e2e/Mobile/FlowTests.cs +++ b/tests/e2e/Mobile/FlowTests.cs @@ -277,6 +277,39 @@ namespace decePubClient.E2E.Mobile }); } + // a picture opens in the app, the whole screen, and closes + [Fact] + public async Task A_picture_opens_in_the_app() + { + var expected = (await Pasture.Town("expect")).AsArray(); + var personas = Pasture.Personas(); + var item = expected.FirstOrDefault(i => i!["alts"]!.AsArray().Count > 0 + && i["readers"]!.AsArray().Any(r => personas.Any(p => p.UserName == (string)r))); + Assert.SkipWhen(item is null, "the town holds no post with pictures"); + var reader = personas.First(p => item!["readers"]!.AsArray().Any(r => (string)r == p.UserName)); + await using var session = await SignIn.As(browser, reader, Phone); + await Flow(session, "picture", async page => + { + await page.GotoAsync($"{Pasture.Site}/expand/{item!["id"]}"); + var card = page.Locator($"article[data-uri='{item["uri"]}']").First; + await card.WaitForAsync(new() { Timeout = 30000 }); + var hidden = card.GetByRole(AriaRole.Button).Filter(new() { HasText = "Sensitive media" }); + for (var i = 0; i < 8 && await hidden.CountAsync() > 0; i++) + await hidden.First.TapAsync(); + await card.Locator("button[data-picture]").First.TapAsync(); + var viewer = page.Locator("[data-media-viewer].modal-open .modal-box"); + await Assertions.Expect(viewer).ToBeVisibleAsync(); + var box = (await viewer.BoundingBoxAsync())!; + var size = page.ViewportSize!; + Assert.True(box.Width >= size.Width - 2 && box.Height >= size.Height - 2, $"the picture viewer is {box.Width}x{box.Height}"); + await AssertWhole(page, "[data-media-viewer] .modal-box"); + if (await page.Locator("[data-picture-next]:enabled").CountAsync() > 0) + await page.Locator("[data-picture-next]").TapAsync(); + await page.Locator("[data-media-viewer] [data-modal-close]").TapAsync(); + await Assertions.Expect(page.Locator("[data-media-viewer].modal-open")).ToHaveCountAsync(0); + }); + } + static async Task Latest(Pasture.Persona persona) => (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault(); }