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 <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:44:22 +02:00
1 parent 7b1bfa71c0
commit 5522fe4fbb
5 files changed
+145 -2

No files matched your search

+13 -2
View File
@@ -525,6 +525,7 @@
bool isReplying; bool isReplying;
bool isDetailsOpen; bool isDetailsOpen;
MediaAttachment viewingPicture;
bool isQuoting; bool isQuoting;
bool isEditing; bool isEditing;
bool isActing; bool isActing;
@@ -626,6 +627,11 @@
</div> </div>
} }
@if (viewingPicture is { } picture)
{
<MediaViewer Pictures="Pictures(status)" Start="Pictures(status).IndexOf(picture)" OnClose="() => viewingPicture = null" />
}
@if (status.Card is { } card && status.MediaAttachments.Count == 0) @if (status.Card is { } card && status.MediaAttachments.Count == 0)
{ {
<a class="flex gap-3 rounded-lg p-2 neo-inset neo-2xs min-w-0" href="@card.Url" target="_blank" rel="noopener noreferrer"> <a class="flex gap-3 rounded-lg p-2 neo-inset neo-2xs min-w-0" href="@card.Url" target="_blank" rel="noopener noreferrer">
@@ -762,10 +768,11 @@
RenderFragment Media(MediaAttachment media, bool several) => media.Type switch RenderFragment Media(MediaAttachment media, bool several) => media.Type switch
{ {
"image" => @<a class="block w-full" href="@media.Url" target="_blank" rel="noopener noreferrer"> "image" => @<button type="button" class="block w-full cursor-zoom-in" @onclick="() => OpenPicture(media)"
aria-label="@(media.Description ?? Localizer["Picture"])" data-picture>
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")" <img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" /> src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" />
</a>, </button>,
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>, "gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>,
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata" "video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url"></video>, poster="@media.PreviewUrl" title="@media.Description" src="@media.Url"></video>,
@@ -1036,6 +1043,10 @@
await OnReplied.InvokeAsync(reply); await OnReplied.InvokeAsync(reply);
} }
static List<MediaAttachment> Pictures(Status status) => [.. status.MediaAttachments.Where(m => m.Type == "image")];
void OpenPicture(MediaAttachment media) => viewingPicture = media;
async Task OpenDetails() async Task OpenDetails()
{ {
isDetailsOpen = true; isDetailsOpen = true;
+75
View File
@@ -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. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]"
BoxClass="w-full max-w-none h-dvh max-h-dvh rounded-none p-3 md:h-auto md:max-h-[90dvh] md:max-w-5xl md:rounded-box md:p-4" data-media-viewer>
<Heading>
<span class="text-sm font-normal opacity-70">@Localizer["Picture {0} of {1}", index + 1, Pictures.Count]</span>
</Heading>
<ChildContent>
<div class="flex flex-col gap-3 mt-2" @onkeydown="OnKeyDown">
<figure class="flex flex-col gap-2 min-h-0">
<img alt="@Current.Description" class="w-full max-h-[calc(100dvh-11rem)] md:max-h-[70dvh] object-contain rounded-lg" src="@Current.Url"
data-blurhash="@Current.Blurhash" />
@if (Current.Description is { Length: > 0 } description)
{
<figcaption class="text-sm opacity-80 break-words">@description</figcaption>
}
</figure>
<div class="flex items-center justify-between gap-2">
@if (Pictures.Count > 1)
{
<DButton Shape="DButtonShape.Circle" Disabled="index == 0" OnClick="Previous" aria-label="@Localizer["Previous picture"]"
title="@Localizer["Previous picture"]" data-picture-previous>
<i class="ion-md-arrow-back" aria-hidden="true"></i>
</DButton>
}
<a class="btn btn-sm mx-auto" href="@Current.Url" target="_blank" rel="noopener noreferrer">
<i class="ion-md-open" aria-hidden="true"></i>
<span>@Localizer["Open original"]</span>
</a>
@if (Pictures.Count > 1)
{
<DButton Shape="DButtonShape.Circle" Disabled="index == Pictures.Count - 1" OnClick="Next" aria-label="@Localizer["Next picture"]"
title="@Localizer["Next picture"]" data-picture-next>
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
</DButton>
}
</div>
</div>
</ChildContent>
</DModal>
@code {
/// <summary>The post's pictures, in its order.</summary>
[Parameter, EditorRequired] public List<MediaAttachment> Pictures { get; set; } = [];
/// <summary>The one opened.</summary>
[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();
}
+12
View File
@@ -1859,4 +1859,16 @@
<data name="Details" xml:space="preserve"> <data name="Details" xml:space="preserve">
<value>Dettagli</value> <value>Dettagli</value>
</data> </data>
<data name="Picture" xml:space="preserve">
<value>Immagine</value>
</data>
<data name="Previous picture" xml:space="preserve">
<value>Immagine precedente</value>
</data>
<data name="Next picture" xml:space="preserve">
<value>Immagine successiva</value>
</data>
<data name="Picture {0} of {1}" xml:space="preserve">
<value>Immagine {0} di {1}</value>
</data>
</root> </root>
+12
View File
@@ -1859,4 +1859,16 @@
<data name="Details" xml:space="preserve"> <data name="Details" xml:space="preserve">
<value>Details</value> <value>Details</value>
</data> </data>
<data name="Picture" xml:space="preserve">
<value>Picture</value>
</data>
<data name="Previous picture" xml:space="preserve">
<value>Previous picture</value>
</data>
<data name="Next picture" xml:space="preserve">
<value>Next picture</value>
</data>
<data name="Picture {0} of {1}" xml:space="preserve">
<value>Picture {0} of {1}</value>
</data>
</root> </root>
+33
View File
@@ -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<JsonNode> Latest(Pasture.Persona persona) => static async Task<JsonNode> Latest(Pasture.Persona persona) =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault(); (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
} }