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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 11:56:59 +02:00
1 parent 9d662f702b
commit 70cb389537
26 files changed
+883 -132

No files matched your search

+71 -23
View File
@@ -18,19 +18,35 @@ else
{
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4 w-full" data-profile-editor>
<div class="relative w-full">
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Base64Preview ?? account.Header)" alt="@Localizer["Header"]" />
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Base64Preview ?? account.Avatar)"
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Preview ?? account.Header)" alt="@Localizer["Header"]" />
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Preview ?? account.Avatar)"
alt="@Localizer["Avatar"]" />
</div>
<div class="flex flex-wrap gap-3 pt-6">
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Avatar"]</span>
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, true)" data-profile-avatar />
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Header"]</span>
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, false)" data-profile-header />
</label>
<div class="flex flex-col gap-1 text-sm">
<label class="flex flex-col gap-1">
<span>@Localizer["Avatar"]</span>
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, true)" data-profile-avatar />
</label>
@if (HasOwn(account.Avatar) && form.Avatar is null)
{
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(true)" data-profile-avatar-remove>
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the avatar"]</span>
</DButton>
}
</div>
<div class="flex flex-col gap-1 text-sm">
<label class="flex flex-col gap-1">
<span>@Localizer["Header"]</span>
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, false)" data-profile-header />
</label>
@if (HasOwn(account.Header) && form.Header is null)
{
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(false)" data-profile-header-remove>
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the header"]</span>
</DButton>
}
</div>
</div>
<label class="flex flex-col gap-1 max-w-md">
@@ -110,11 +126,10 @@ else
@code {
[Inject] AccountService AccountService { get; set; }
[Inject] MediaService MediaService { get; set; }
[Inject] AuthService AuthService { get; set; }
[Inject] ILoggingService Logger { get; set; }
// PrivaPub keeps pictures up to 20 MB and resizes them
const long MaximumPictureSize = 20 * 1024 * 1024;
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
Account account;
@@ -122,6 +137,13 @@ else
string loadedPersonaId;
bool isSaving;
List<string> pictureErrors = [];
// the picture types the server takes, read with the account
List<string> pictureTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
string PictureTypes => string.Join(",", pictureTypes);
// a picture of its own, not the server's default
static bool HasOwn(string url) => url is { Length: > 0 } && !url.Contains("/missing-", StringComparison.Ordinal);
protected override async Task OnParametersSetAsync()
{
@@ -145,6 +167,7 @@ else
{
// the account as the persona sees itself, with its Source
account = await AuthService.CurrentAccount();
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/"))];
Reset();
}
@@ -165,30 +188,55 @@ else
{
pictureErrors = [];
var file = eventArgs.File;
if (file.Size > MaximumPictureSize)
if (!pictureTypes.Contains(file.ContentType))
{
pictureErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
return;
}
if (file.Size > ProfileForm.MaximumPictureSize)
{
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
file.Size.GetFileSize(Localizer), MaximumPictureSize.GetFileSize(Localizer)]);
file.Size.GetFileSize(Localizer), ProfileForm.MaximumPictureSize.GetFileSize(Localizer)]);
return;
}
// the file is sent as it is when saving; only a small picture of it is kept to show it now
var picture = new UploadMedia { File = file, FileName = file.Name, ContentType = file.ContentType, Size = file.Size };
try
{
using var memoryStream = new MemoryStream();
await using var fileStream = file.OpenReadStream(MaximumPictureSize);
await fileStream.CopyToAsync(memoryStream);
var picture = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
picture.Base64Preview = $"data:{picture.ContentType};base64,{Convert.ToBase64String(picture.Blob)}";
if (isAvatar)
form.Avatar = picture;
else
form.Header = picture;
var small = await file.RequestImageFileAsync("image/jpeg", isAvatar ? 240 : 900, isAvatar ? 240 : 300);
await using var stream = small.OpenReadStream(4 * 1024 * 1024);
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer);
picture.Preview = $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
pictureErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
return;
}
if (isAvatar)
form.Avatar = picture;
else
form.Header = picture;
}
async Task RemovePicture(bool avatar)
{
isSaving = true;
var result = await AccountService.RemovePicture(avatar);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
// the rest of the form stays as being written: it shows the server's pictures, which are the account's
account = (Account)result.Data;
CascadingState.PersonaChanged(account);
Toast.AddResponseSuccess(avatar ? Localizer["Avatar removed."] : Localizer["Header removed."]);
}
async Task Save()