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
106 lines
4.1 KiB
C#
106 lines
4.1 KiB
C#
using decePubClient.Models.Mastodon;
|
|
using decePubClient.Models.Types;
|
|
|
|
namespace decePubClient.Models
|
|
{
|
|
/// <summary>
|
|
/// The persona's profile as Settings edits it, which is what update_credentials takes: the words as written (its
|
|
/// account's Source), the flags, the posting defaults, and a new avatar or header when one was picked. Fields always
|
|
/// holds FieldCount rows; a row without a name is dropped.
|
|
/// </summary>
|
|
public class ProfileForm
|
|
{
|
|
public const int FieldCount = 4;
|
|
public const int DisplayNameLength = 30;
|
|
public const int NoteLength = 500;
|
|
public const int FieldLength = 255;
|
|
|
|
public string DisplayName { get; set; }
|
|
public string Note { get; set; }
|
|
public bool Locked { get; set; }
|
|
public bool Bot { get; set; }
|
|
public bool Discoverable { get; set; }
|
|
public bool Indexable { get; set; }
|
|
public MessageType Privacy { get; set; } = MessageType.Public;
|
|
public bool Sensitive { get; set; }
|
|
public string Language { get; set; }
|
|
public QuotePolicy QuotePolicy { get; set; } = QuotePolicy.Public;
|
|
public List<AccountField> Fields { get; set; } = [];
|
|
public UploadMedia Avatar { get; set; }
|
|
public UploadMedia Header { get; set; }
|
|
|
|
public static ProfileForm Of(Account account)
|
|
{
|
|
var source = account.Source ?? new();
|
|
var fields = source.Fields.Count > 0 ? source.Fields : account.Fields;
|
|
return new()
|
|
{
|
|
DisplayName = account.DisplayName,
|
|
Note = source.Note,
|
|
Locked = account.Locked,
|
|
Bot = account.Bot,
|
|
Discoverable = account.Discoverable == true,
|
|
Indexable = account.Indexable == true,
|
|
Privacy = source.Privacy switch
|
|
{
|
|
"private" => MessageType.FollowersOnly,
|
|
"unlisted" => MessageType.Unlisted,
|
|
_ => MessageType.Public
|
|
},
|
|
Sensitive = source.Sensitive,
|
|
Language = source.Language,
|
|
QuotePolicy = source.QuotePolicy switch
|
|
{
|
|
"followers" => QuotePolicy.Followers,
|
|
"nobody" => QuotePolicy.Nobody,
|
|
_ => QuotePolicy.Public
|
|
},
|
|
Fields = [.. fields.Take(FieldCount).Select(f => new AccountField { Name = f.Name, Value = f.Value }),
|
|
.. Enumerable.Range(0, Math.Max(0, FieldCount - fields.Count)).Select(_ => new AccountField())]
|
|
};
|
|
}
|
|
|
|
/// <summary>The form as update_credentials reads it, with the pictures as files.</summary>
|
|
public MultipartFormDataContent Content()
|
|
{
|
|
var content = new MultipartFormDataContent
|
|
{
|
|
{ new StringContent(DisplayName?.Trim() ?? ""), "display_name" },
|
|
{ new StringContent(Note ?? ""), "note" },
|
|
{ new StringContent(Flag(Locked)), "locked" },
|
|
{ new StringContent(Flag(Bot)), "bot" },
|
|
{ new StringContent(Flag(Discoverable)), "discoverable" },
|
|
{ new StringContent(Flag(Indexable)), "indexable" },
|
|
{ new StringContent(Privacy switch { MessageType.FollowersOnly => "private", MessageType.Unlisted => "unlisted", _ => "public" }), "source[privacy]" },
|
|
{ new StringContent(Flag(Sensitive)), "source[sensitive]" },
|
|
{ new StringContent(Language ?? ""), "source[language]" },
|
|
{ new StringContent(QuotePolicy switch { QuotePolicy.Followers => "followers", QuotePolicy.Nobody => "nobody", _ => "public" }), "source[quote_policy]" }
|
|
};
|
|
// every row is sent, so a row emptied here is removed there
|
|
for (var i = 0; i < Fields.Count; i++)
|
|
{
|
|
content.Add(new StringContent(Fields[i].Name?.Trim() ?? ""), $"fields_attributes[{i}][name]");
|
|
content.Add(new StringContent(Fields[i].Value?.Trim() ?? ""), $"fields_attributes[{i}][value]");
|
|
}
|
|
if (Avatar is not null)
|
|
content.Add(Picture(Avatar), "avatar", Avatar.FileName);
|
|
if (Header is not null)
|
|
content.Add(Picture(Header), "header", Header.FileName);
|
|
return content;
|
|
}
|
|
|
|
static string Flag(bool value) => value ? "true" : "false";
|
|
|
|
// 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 StreamContent(media.File.OpenReadStream(MaximumPictureSize));
|
|
picture.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(media.ContentType);
|
|
return picture;
|
|
}
|
|
}
|
|
}
|