From 70cb3895378b89d1211d613cc8cbf083b17ccd62 Mon Sep 17 00:00:00 2001 From: thepra Date: Wed, 7 Oct 2026 11:56:59 +0200 Subject: [PATCH] Media survive failures: uploaded when attached, the server's limits, alt text asked for The composer uploads each file as soon as it is attached (streamed from the browser file, under its own ten-minute timeout), shows it uploading, processing (a 202 followed until ready), ready or failed with Retry, and cancels one removed; publishing sends the ids, so a failed publish or a retried upload sends nothing twice. Size limits and types come from /api/v2/instance, a file without a browser type gets one from its extension, and each refusal names the file and why. Previews are 320 px thumbnails, not whole files in memory. A picture without a description is asked about once ("Publish anyway"; a setting turns it off), descriptions are sent with PUT, and an edit keeps the post's media, language and sensitive flag. Profile pictures are checked against the server's types and can be removed. A card shows "Media unavailable" with the original's link when a picture fails, the blurhash behind a sensitive placeholder, and plays a GIF only on tap under reduced motion. A failed export says so instead of downloading part of a file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 28 ++- Components/Composer.razor | 238 ++++++++++++++++++++---- Components/Content.razor | 32 +++- Components/Settings/DataTransfer.razor | 10 +- Components/Settings/ProfileEditor.razor | 94 +++++++--- MastodonJsonContext.cs | 1 + Models/APIs.cs | 5 + Models/ComposeForm.cs | 2 + Models/Mastodon/InstanceV2.cs | 23 +++ Models/Mastodon/PrivaPubApi.cs | 12 +- Models/PageSettings.cs | 2 + Models/ProfileForm.cs | 10 +- Models/UploadMedia.cs | 41 +++- Pages/Settings.razor | 3 + Program.cs | 2 + Resources/AllStrings.it.resx | 42 +++++ Resources/AllStrings.resx | 42 +++++ Services/AccountService.cs | 14 ++ Services/AppStatusService.cs | 5 + Services/IHttpService.cs | 16 +- Services/MediaService.cs | 64 ++++++- Services/PortabilityService.cs | 53 ++++-- Services/StatusService.cs | 46 +++-- tests/e2e/Actions/MediaTests.cs | 184 ++++++++++++++++++ wwwroot/js/blurhash.js | 35 ++-- wwwroot/main.js | 11 ++ 26 files changed, 883 insertions(+), 132 deletions(-) create mode 100644 Models/Mastodon/InstanceV2.cs create mode 100644 tests/e2e/Actions/MediaTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index f34cd10..6cd3d0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,6 +274,8 @@ supplies: them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT: PrivaPub redirects it). - `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes. +- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the + `HttpClient`'s own is off. - Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from `Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410. - Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's @@ -309,8 +311,10 @@ supplies: - **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:` in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse post's downvotes next to its favourites, blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js` - watches the page for `img[data-blurhash]`), a picture's focal point (`object-position`), and an event's online - link, participants and status. + paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the + sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and + status. A picture, video or sound that fails to load says "Media unavailable" with "Open original" + (`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`. - **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's author and a notification's account open the profile in the app; "Open original" is the only way to the remote site. A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the @@ -340,8 +344,21 @@ supplies: than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the post opened in its thread shows whole. - **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a - card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and - warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media. + card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`); a post can hold + a poll instead of media. + - **Media are uploaded as soon as they are picked** (`Services/MediaService.cs`, `Models/UploadMedia.cs`): + - each is streamed from the browser's file, and only a 320 px thumbnail (`RequestImageFileAsync`) is kept to show it; + - each shows its state (`data-upload-state`: uploading, processing, ready, or failed with Retry); + - audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready); + - the ids are kept, so a post that fails never uploads its media again; + - the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`); + - a description changed after the upload is PUT before the post. + - **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless + the reader turned `PageSettings.RemindAltText` off in Settings → General. + - **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and + sensitive flag, the media it keeps or adds (`media_ids`; removing them all is `[]`), and the descriptions changed + (`media_attributes`). Its visibility and poll stay. + - A new post's media are sensitive when the persona's "always mark media as sensitive" (`source.sensitive`) says so. A mention or hashtag being written at the end of the words is offered what PrivaPub already knows (`/api/v2/search`, never resolving), once typing pauses. An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub @@ -355,7 +372,8 @@ supplies: - **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed, or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; the client then signs out), the persona's profile (`ProfileEditor`: - `update_credentials` as multipart, its words as written from `Account.Source`, pictures, fields, locked, bot, + `update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file, + and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot, discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes, blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import diff --git a/Components/Composer.razor b/Components/Composer.razor index f51b0f3..ea0ccc5 100644 --- a/Components/Composer.razor +++ b/Components/Composer.razor @@ -5,8 +5,10 @@ visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of this device's position and never federated; a group post goes to the group. Both of those go through /clientapi. A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those. - It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words - and warning change, its media, visibility and poll stay). *@ + It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words, + warning, media and their descriptions change; its visibility and poll stay). A file is uploaded as soon as it is + picked, streamed from the browser's file, and shows its state (uploading, processing, ready, failed with Retry); its + id is kept, so a post that fails never uploads it again. What the server takes (types, sizes) comes from it. *@ @@ -83,29 +85,61 @@ @if (form.Media.Count > 0) { -
+
@foreach (var media in form.Media) { -
- @if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image")) +
+ @if (media.Preview is { Length: > 0 } preview) { @media.AltText } else { - + }
-

@media.FileName · @media.ContentType · @media.Size.GetFileSize(Localizer)

- +

@media.FileName@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)

+

+ @switch (media.State) + { + case UploadState.Uploading: + @Localizer["Uploading"] + break; + case UploadState.Processing: + @Localizer["Processing"] + break; + case UploadState.Failed: + @media.Error + @if (media.File is not null) + { + @Localizer["Retry"] + } + break; + default: + @Localizer["Ready"] + break; + } +

+
- +
}
+ @if (AskingAltText) + { + + @Localizer["{0} of the pictures have no description, which people who can't see them rely on.", form.Media.Count(Undescribed)] +
+ @Localizer["Describe them"] + @Localizer["Publish anyway"] +
+
+ } } @if (form.IsLocated) @@ -165,12 +199,12 @@ - @if (!form.IsLocated && GroupId is null && Editing is null && !form.HasPoll) + @if (!form.IsLocated && GroupId is null && !form.HasPoll) { @* A label styled as a button opens the hidden file input. *@
; - RenderFragment Media(MediaAttachment media, bool several) => media.Type switch + // media that didn't load (gone from their server, or refused): said so, with the way to their original + HashSet unavailableMedia = []; + + RenderFragment Media(MediaAttachment media, bool several) => unavailableMedia.Contains(media.Id) ? Unavailable(media) : media.Type switch { "image" => @, - "gifv" => @, + // under reduced motion a gifv waits for a tap (its controls) + "gifv" => @, "video" => @, - "audio" => @, + poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)">, + "audio" => @, _ => @ @(media.Description ?? media.Url) }; + RenderFragment Unavailable(MediaAttachment media) => + @
+ + @Localizer["Media unavailable"] + @if ((media.RemoteUrl ?? media.Url) is { Length: > 0 } original) + { + @Localizer["Open original"] + } +
; + async Task Act(StatusAction action) { isActing = true; diff --git a/Components/Settings/DataTransfer.razor b/Components/Settings/DataTransfer.razor index e1a8a5a..730d485 100644 --- a/Components/Settings/DataTransfer.razor +++ b/Components/Settings/DataTransfer.razor @@ -95,9 +95,15 @@ async Task Export(PortableData data) { exporting = data; - var bytes = await PortabilityService.Export(data, CascadingState.PersonaId); + var result = await PortabilityService.Export(data, CascadingState.PersonaId); exporting = null; - await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), bytes, "text/csv"); + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), (byte[])result.Data, "text/csv"); } async Task Pick(InputFileChangeEventArgs eventArgs) diff --git a/Components/Settings/ProfileEditor.razor b/Components/Settings/ProfileEditor.razor index 74f7de4..829ac55 100644 --- a/Components/Settings/ProfileEditor.razor +++ b/Components/Settings/ProfileEditor.razor @@ -18,19 +18,35 @@ else {
- @Localizer[ - @Localizer[ + @Localizer[
- - +
+ + @if (HasOwn(account.Avatar) && form.Avatar is null) + { + + @Localizer["Remove the avatar"] + + } +
+
+ + @if (HasOwn(account.Header) && form.Header is null) + { + + @Localizer["Remove the header"] + + } +