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

+14
View File
@@ -221,6 +221,20 @@ namespace decePubClient.Services
: new WebResult { Data = account };
}
/// <summary>Removes the persona's avatar or header (the default shows again); Data is the account as it is now.</summary>
public async Task<WebResult> RemovePicture(bool avatar, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(avatar ? APIs.Mastodon.DELETE_ProfileAvatar : APIs.Mastodon.DELETE_ProfileHeader,
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
return account?.Id is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
: new WebResult { Data = account };
}
/// <summary>A page of the accounts the persona follows (only the persona itself may read it); Data is an AccountPage.</summary>
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
+5
View File
@@ -48,6 +48,11 @@ public class AppStatusService(IJSRuntime jsRuntime)
// whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap
public bool CanHover() => JsSyncRuntime.Invoke<bool>("canHover");
// read once: a card asks it for each gifv it shows
bool? reducedMotion;
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
// a sideways-scrolling strip of tabs shows its selected tab
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
+15 -1
View File
@@ -310,10 +310,24 @@ namespace decePubClient.Services
}
/// <summary>Sends the request and records it in RequestStats.</summary>
static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(100);
static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(10);
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
{
// as HttpClient's own timeout would, but per request: a multipart body (an upload) may take minutes
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(request.Content is MultipartContent ? UploadTimeout : RequestTimeout);
var started = Stopwatch.GetTimestamp();
var response = await httpClient.SendAsync(request, cancellationToken);
HttpResponseMessage response;
try
{
response = await httpClient.SendAsync(request, timeout.Token);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new TaskCanceledException("The request timed out.", new TimeoutException(ex.Message));
}
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
+59 -5
View File
@@ -11,16 +11,41 @@ using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>Uploads a picked file to PrivaPub (/api/v2/media, multipart) before the post that uses it.</summary>
/// <summary>
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
/// (audio and video: 202, then 206 until ready); describes what isn't posted yet; and knows what PrivaPub takes.
/// </summary>
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
{
/// <summary>The uploaded attachment as Data; its id goes in the post's media_ids.</summary>
public async Task<WebResult> Upload(UploadMedia media, CancellationToken cancellationToken = default)
static readonly MediaConfiguration Fallback = new()
{
SupportedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "video/mp4", "video/quicktime", "video/webm",
"audio/mpeg", "audio/mp3", "audio/ogg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/x-m4a", "audio/flac", "audio/webm"],
ImageSizeLimit = 16 * 1024 * 1024,
VideoSizeLimit = 99 * 1024 * 1024
};
MediaConfiguration limits;
/// <summary>What PrivaPub takes, read once a session (/api/v2/instance); its defaults while it can't be read.</summary>
public async Task<MediaConfiguration> Limits(CancellationToken cancellationToken = default)
{
if (limits is not null)
return limits;
using var response = await httpService.GetAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode
&& await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) is { Configuration.MediaAttachments.SupportedMimeTypes.Count: > 0 } instance)
return limits = instance.Configuration.MediaAttachments;
return Fallback;
}
/// <summary>The attachment as Data; its Url is null while PrivaPub still processes it (follow it with Get).</summary>
public async Task<WebResult> Upload(UploadMedia media, long maximumSize, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
{
var file = new ByteArrayContent(media.Blob);
file.Headers.ContentType = MediaTypeHeaderValue.Parse(media.ContentType);
var file = new StreamContent(media.File.OpenReadStream(maximumSize, cancellationToken));
file.Headers.ContentType = new MediaTypeHeaderValue(media.ContentType);
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
if (media.AltText is { Length: > 0 } description)
content.Add(new StringContent(description), "description");
@@ -29,6 +54,35 @@ namespace decePubClient.Services
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return await Attachment(response, cancellationToken);
}
/// <summary>An upload PrivaPub is processing: Data is the attachment once it is ready, null while it isn't; invalid
/// when it can't be.</summary>
public async Task<WebResult> Get(string mediaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_Media(mediaId), cancellationToken: cancellationToken);
if (response.StatusCode == HttpStatusCode.PartialContent)
return new WebResult();
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return await Attachment(response, cancellationToken);
}
/// <summary>Describes an upload not posted yet (a posted one is described again by editing its post).</summary>
public async Task<WebResult> Describe(string mediaId, string description, CancellationToken cancellationToken = default)
{
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Media(mediaId),
new KeyValuePair<string, string>[] { new("description", description ?? string.Empty) }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
{
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
return attachment?.Id is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
+35 -18
View File
@@ -1,3 +1,4 @@
using collAnon.Client.Services;
using System.Text;
using decePubClient.Extensions;
@@ -35,7 +36,7 @@ namespace decePubClient.Services
/// reports the rows it could not do.
/// </summary>
public class PortabilityService(AccountService accounts, ListService lists, StatusService statuses, TimelineService timelines,
InstanceService instances)
InstanceService instances, CoalescingStringLocalizer localizer)
{
/// <summary>The most rows read from an export or an import file.</summary>
public const int MaxRows = 5000;
@@ -53,23 +54,33 @@ namespace decePubClient.Services
};
/// <summary>The file Mastodon would export, as UTF-8 bytes.</summary>
public async Task<byte[]> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
// a page that could not be read stops the export, which says so, instead of a file silently missing the rest
sealed class Stop
{
var rows = data switch
{
PortableData.Follows => await Follows(personaId, cancellationToken),
PortableData.Lists => await Lists(cancellationToken),
PortableData.Blocks => [.. (await Pages(accounts.Blocks, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
PortableData.Mutes => await Mutes(cancellationToken),
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
_ => await Bookmarks(personaId, cancellationToken)
};
return Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n")));
public WebResult Failed { get; set; }
}
async Task<List<string>> Follows(string personaId, CancellationToken cancellationToken)
/// <summary>The file, as Data (bytes); invalid when a page of it could not be read.</summary>
public async Task<WebResult> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
{
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), cancellationToken);
var stop = new Stop();
var rows = data switch
{
PortableData.Follows => await Follows(personaId, stop, cancellationToken),
PortableData.Lists => await Lists(cancellationToken),
PortableData.Blocks => [.. (await Pages(accounts.Blocks, stop, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
PortableData.Mutes => await Mutes(stop, cancellationToken),
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
_ => await Bookmarks(personaId, stop, cancellationToken)
};
if (stop.Failed is { } failed)
return failed;
return new WebResult { Data = Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n"))) };
}
async Task<List<string>> Follows(string personaId, Stop stop, CancellationToken cancellationToken)
{
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), stop, cancellationToken);
var relationships = (await accounts.Relationships([.. followed.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
return
[
@@ -80,9 +91,9 @@ namespace decePubClient.Services
];
}
async Task<List<string>> Mutes(CancellationToken cancellationToken)
async Task<List<string>> Mutes(Stop stop, CancellationToken cancellationToken)
{
var muted = await Pages(accounts.Mutes, cancellationToken);
var muted = await Pages(accounts.Mutes, stop, cancellationToken);
var relationships = (await accounts.Relationships([.. muted.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
return
[
@@ -99,7 +110,7 @@ namespace decePubClient.Services
return rows;
}
async Task<List<string>> Bookmarks(string personaId, CancellationToken cancellationToken)
async Task<List<string>> Bookmarks(string personaId, Stop stop, CancellationToken cancellationToken)
{
var rows = new List<string>();
var maxId = default(string);
@@ -107,7 +118,10 @@ namespace decePubClient.Services
{
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
if (result.IsInvalid || result.Data is not FeedPage page)
{
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
return rows;
}
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
maxId = page.Posts.Count == 0 ? null : page.MaxId;
}
@@ -116,7 +130,7 @@ namespace decePubClient.Services
}
// every page of an account list, up to MaxRows
static async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, CancellationToken cancellationToken)
async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, Stop stop, CancellationToken cancellationToken)
{
var all = new List<Account>();
var maxId = default(string);
@@ -124,7 +138,10 @@ namespace decePubClient.Services
{
var result = await page(maxId, cancellationToken);
if (result.IsInvalid || result.Data is not AccountPage accountPage)
{
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
return all;
}
all.AddRange(accountPage.Accounts);
maxId = accountPage.MaxId;
}
+33 -13
View File
@@ -33,27 +33,25 @@ namespace decePubClient.Services
/// </summary>
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>Publishes the form as the persona; its media are uploaded first.</summary>
/// <summary>Publishes the form as the persona. Its media are uploaded already (the composer uploads a file as soon as
/// it is picked); a description changed since is sent first.</summary>
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
{
if (form.IsLocated || form.GroupId is not null)
return await PublishThroughClientApi(form, personaId, cancellationToken);
var mediaIds = new List<string>();
foreach (var media in form.Media)
{
var uploaded = await mediaService.Upload(media, cancellationToken);
if (uploaded.IsInvalid)
return uploaded;
mediaIds.Add(((MediaAttachment)uploaded.Data).Id);
}
if (form.Media.Any(m => m.State != UploadState.Ready))
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
return undescribed;
var mediaIds = form.Media.Select(m => m.Id).ToList();
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
{
Status = form.Content ?? string.Empty,
MediaIds = mediaIds,
InReplyToId = form.InReplyToId,
Sensitive = form.HasContentWarning,
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.Visibility,
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
@@ -70,6 +68,20 @@ namespace decePubClient.Services
return new WebResult { Data = FeedPost.Of(status) };
}
// a new upload whose description changed after it was picked tells PrivaPub before the post (a posted one is
// described again through the edit's media_attributes)
async Task<WebResult> Describe(ComposeForm form, CancellationToken cancellationToken)
{
foreach (var media in form.Media.Where(m => !m.Posted && m.AltText != m.SentAltText))
{
var described = await mediaService.Describe(media.Id, media.AltText, cancellationToken);
if (described.IsInvalid)
return described;
media.SentAltText = media.AltText;
}
return new WebResult();
}
/// <summary>The persona's own text of a post, to edit it; null when it cannot be read.</summary>
public async Task<StatusSource> Source(string statusId, CancellationToken cancellationToken = default)
{
@@ -104,15 +116,23 @@ namespace decePubClient.Services
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
}
/// <summary>Edits the persona's own post: new words and warning, the media kept; Data is the edited Status.</summary>
/// <summary>Edits the persona's own post, sent whole as Mastodon wants it: its words and warning, its sensitive flag
/// and language as they were unless changed, the media it keeps (new ones uploaded already) and the descriptions
/// changed; Data is the edited Status.</summary>
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
{
if (form.Media.Any(m => m.State != UploadState.Ready))
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
return undescribed;
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Status(statusId), new StatusEditForm
{
Status = form.Content ?? string.Empty,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Sensitive = form.HasContentWarning,
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
MediaIds = [.. form.Media.Select(m => m.Id)],
MediaAttributes = [.. form.Media.Where(m => m.Posted && m.AltText != m.SentAltText).Select(m => new MediaAttributeForm { Id = m.Id, Description = m.AltText ?? string.Empty })]
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);