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"] + + } +