Files
decePubClient/Components/Settings/ProfileEditor.razor
T
thepraandClaude Opus 5.5 70cb389537 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
2026-10-07 11:56:59 +02:00

259 lines
9.7 KiB
Plaintext

@inherits LocalizableComponentBase
@* The current persona's profile (/api/v1/accounts/update_credentials): its pictures, name, biography and fields as it
wrote them, whether it approves followers by hand, is a bot, is listed and searchable, and how its new posts start.
PrivaPub tells the persona's followers' servers of the change. *@
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (account is null)
{
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
}
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?.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">
<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">
<span class="text-sm">@Localizer["Display name"]</span>
<DInput TValue="string" @bind-Value="form.DisplayName" Immediate="true" autocomplete="off" maxlength="@ProfileForm.DisplayNameLength"
data-profile-name />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Biography"]</span>
<DTextarea @bind-Value="form.Note" Immediate="true" rows="4" autocomplete="off" maxlength="@ProfileForm.NoteLength" data-profile-note />
<span class="text-xs opacity-70 tabular-nums self-end">@(form.Note?.Length ?? 0)/@ProfileForm.NoteLength</span>
</label>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-semibold pb-1">@Localizer["Profile fields"]</legend>
@foreach (var field in form.Fields)
{
<div class="flex flex-wrap gap-2" data-profile-field="@form.Fields.IndexOf(field)">
<DInput TValue="string" class="flex-1 min-w-36" @bind-Value="field.Name" Immediate="true" autocomplete="off"
maxlength="@ProfileForm.FieldLength" placeholder="@Localizer["Label"]" />
<DInput TValue="string" class="flex-[2] min-w-48" @bind-Value="field.Value" Immediate="true" autocomplete="off"
maxlength="@ProfileForm.FieldLength" placeholder="@Localizer["Content"]" />
</div>
}
</fieldset>
<div class="flex flex-col gap-3">
<DToggle LabelText="@Localizer["Approve followers by hand"]" @bind-Value="form.Locked" data-profile-locked />
<DToggle LabelText="@Localizer["This persona is automated (a bot)"]" @bind-Value="form.Bot" />
<DToggle LabelText="@Localizer["List it in the directory and suggest it to others"]" @bind-Value="form.Discoverable" />
<DToggle LabelText="@Localizer["Let its public posts be found by search"]" @bind-Value="form.Indexable" />
</div>
<fieldset class="flex flex-wrap gap-4">
<legend class="text-sm font-semibold pb-1">@Localizer["New posts"]</legend>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Visibility"]</span>
<DSelect TValue="MessageType" Size="DSize.Sm" @bind-Value="form.Privacy" data-profile-privacy>
@foreach (var type in Visibilities)
{
<option value="@type">@Localizer[type.ToString()]</option>
}
</DSelect>
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Language"]</span>
<DSelect TValue="string" Size="DSize.Sm" @bind-Value="form.Language">
<option value="">—</option>
@foreach (var (languageCode, languageName) in Languages.All)
{
<option value="@languageCode">@languageName</option>
}
</DSelect>
</label>
<label class="flex flex-col gap-1 text-sm">
<span>@Localizer["Who may quote them"]</span>
<DSelect TValue="QuotePolicy" Size="DSize.Sm" @bind-Value="form.QuotePolicy">
@foreach (var policy in Enum.GetValues<QuotePolicy>())
{
<option value="@policy">@QuotePolicyName(policy)</option>
}
</DSelect>
</label>
<DToggle class="self-end" LabelText="@Localizer["Mark media as sensitive"]" @bind-Value="form.Sensitive" />
</fieldset>
<ErrorViewer AdditionalErrors="pictureErrors" />
<div class="flex flex-wrap gap-2 justify-end">
<DButton Size="DSize.Sm" OnClick="Reset" Disabled="@(IsDefaultDisabled || isSaving)">@Localizer["Reset"]</DButton>
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-profile-save>
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
</DButton>
</div>
</EditForm>
}
@code {
[Inject] AccountService AccountService { get; set; }
[Inject] MediaService MediaService { get; set; }
[Inject] AuthService AuthService { get; set; }
[Inject] ILoggingService Logger { get; set; }
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
Account account;
ProfileForm form = new();
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()
{
if (CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
// 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();
}
void Reset()
{
pictureErrors = [];
form = account is null ? new() : ProfileForm.Of(account);
}
string QuotePolicyName(QuotePolicy policy) => policy switch
{
QuotePolicy.Followers => Localizer["Its followers"],
QuotePolicy.Nobody => Localizer["Nobody"],
_ => Localizer["Anyone"]
};
async Task Pick(InputFileChangeEventArgs eventArgs, bool isAvatar)
{
pictureErrors = [];
var file = eventArgs.File;
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), 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
{
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()
{
isSaving = true;
var result = await AccountService.UpdateProfile(form);
isSaving = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
account = (Account)result.Data;
CascadingState.PersonaChanged(account);
Reset();
Toast.AddResponseSuccess(Localizer["Profile saved."]);
}
}