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:
1 parent
9d662f702b
commit
70cb389537
26 files changed
+883
-132
No files matched your search
@@ -73,6 +73,11 @@ public static class APIs
|
||||
public static string DELETE_M_Conversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}";
|
||||
public const string GET_Notifications = "api/v1/notifications";
|
||||
public const string POST_Media = "api/v2/media";
|
||||
public static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||
public const string GET_InstanceV2 = "api/v2/instance";
|
||||
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
|
||||
public const string DELETE_ProfileHeader = "api/v1/profile/header";
|
||||
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
||||
public const string GET_Relationships = "api/v1/accounts/relationships";
|
||||
public const string GET_FollowRequests = "api/v1/follow_requests";
|
||||
|
||||
@@ -24,6 +24,8 @@ public class ComposeForm
|
||||
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||
public MessageType MessageType { get; set; } = MessageType.Public;
|
||||
public bool HasContentWarning { get; set; }
|
||||
// its media marked sensitive without a warning: the persona's default ("always mark media as sensitive"), or the post's own when edited
|
||||
public bool Sensitive { get; set; }
|
||||
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||
public string SpoilerText { get; set; }
|
||||
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace decePubClient.Models.Mastodon;
|
||||
|
||||
/// <summary>/api/v2/instance, the part the client reads: what an upload may be.</summary>
|
||||
public class InstanceV2
|
||||
{
|
||||
public InstanceConfiguration Configuration { get; set; } = new();
|
||||
}
|
||||
|
||||
public class InstanceConfiguration
|
||||
{
|
||||
public MediaConfiguration MediaAttachments { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>The types an upload may have and how big it may be, as the server enforces them.</summary>
|
||||
public class MediaConfiguration
|
||||
{
|
||||
public List<string> SupportedMimeTypes { get; set; } = [];
|
||||
public long ImageSizeLimit { get; set; }
|
||||
public long VideoSizeLimit { get; set; }
|
||||
public long ImageMatrixLimit { get; set; }
|
||||
public long VideoMatrixLimit { get; set; }
|
||||
public int VideoFrameRateLimit { get; set; }
|
||||
}
|
||||
@@ -189,13 +189,23 @@ public class PollForm
|
||||
public bool Multiple { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>What an edit sends: the words and their warning; the media stay as they are.</summary>
|
||||
/// <summary>What an edit sends, the whole post as Mastodon wants it: the words, their warning and sensitive flag, the
|
||||
/// post's language, the media it keeps (media_ids) and the descriptions changed (media_attributes).</summary>
|
||||
public class StatusEditForm
|
||||
{
|
||||
public string Status { get; set; }
|
||||
public string SpoilerText { get; set; }
|
||||
public bool Sensitive { get; set; }
|
||||
public string Language { get; set; }
|
||||
public List<string> MediaIds { get; set; } = [];
|
||||
public List<MediaAttributeForm> MediaAttributes { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A posted attachment's new description, sent with the edit.</summary>
|
||||
public class MediaAttributeForm
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>/api/v1/statuses/:id/source: the author's own text of a post, to edit it.</summary>
|
||||
|
||||
@@ -24,6 +24,8 @@ namespace decePubClient.Models
|
||||
public bool ThemeIsDarkGray { get; set; } = false;
|
||||
|
||||
public bool ShowDonatorBadge { get; set; } = false;
|
||||
// before publishing pictures without a description, the composer asks once
|
||||
public bool RemindAltText { get; set; } = true;
|
||||
|
||||
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
|
||||
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
|
||||
|
||||
@@ -91,10 +91,14 @@ namespace decePubClient.Models
|
||||
|
||||
static string Flag(bool value) => value ? "true" : "false";
|
||||
|
||||
static ByteArrayContent Picture(UploadMedia media)
|
||||
// PrivaPub keeps pictures up to this size and resizes them
|
||||
public const long MaximumPictureSize = 20 * 1024 * 1024;
|
||||
|
||||
// streamed from the browser's file, read again for each attempt
|
||||
static StreamContent Picture(UploadMedia media)
|
||||
{
|
||||
var picture = new ByteArrayContent(media.Blob);
|
||||
picture.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(media.ContentType);
|
||||
var picture = new StreamContent(media.File.OpenReadStream(MaximumPictureSize));
|
||||
picture.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(media.ContentType);
|
||||
return picture;
|
||||
}
|
||||
}
|
||||
|
||||
+36
-5
@@ -1,17 +1,48 @@
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
|
||||
namespace decePubClient.Models;
|
||||
|
||||
public enum UploadState
|
||||
{
|
||||
Uploading,
|
||||
Processing,
|
||||
Ready,
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file attached in the composer. It is uploaded as soon as it is picked, streamed from the browser's file (nothing of
|
||||
/// it is kept but a small preview), and its id is kept, so a post that fails never sends it again.
|
||||
/// </summary>
|
||||
public class UploadMedia
|
||||
{
|
||||
public string ContentType { get; set; }
|
||||
public string FileName { get; set; }
|
||||
public string AltText { get; set; }
|
||||
public byte[] Blob { get; set; } = [];
|
||||
public long Size { get; set; }
|
||||
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public string Base64Preview { get; set; }
|
||||
public IBrowserFile File { get; set; }
|
||||
/// <summary>A small picture of it (a thumbnail the browser made), or for media already posted its server's preview.</summary>
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public long Size { get; set; }
|
||||
}
|
||||
public string Preview { get; set; }
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public UploadState State { get; set; }
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public string Id { get; set; }
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public string Error { get; set; }
|
||||
/// <summary>The description the server holds: a different one is sent before the post.</summary>
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public string SentAltText { get; set; }
|
||||
/// <summary>Already on the post being edited: its description changes through the edit.</summary>
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public bool Posted { get; set; }
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public CancellationTokenSource Cancel { get; set; }
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public Task Done { get; set; } = Task.CompletedTask;
|
||||
}
|
||||
Reference in new issue
Block a user