2026-10-05 06:20:18 +02:00
|
|
|
@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">
|
2026-10-07 11:56:59 +02:00
|
|
|
<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)"
|
2026-10-05 06:20:18 +02:00
|
|
|
alt="@Localizer["Avatar"]" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-wrap gap-3 pt-6">
|
2026-10-07 11:56:59 +02:00
|
|
|
<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>
|
2026-10-05 06:20:18 +02:00
|
|
|
</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; }
|
2026-10-07 11:56:59 +02:00
|
|
|
[Inject] MediaService MediaService { get; set; }
|
2026-10-05 06:20:18 +02:00
|
|
|
[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 = [];
|
2026-10-07 11:56:59 +02:00
|
|
|
// 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);
|
2026-10-05 06:20:18 +02:00
|
|
|
|
|
|
|
|
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();
|
2026-10-07 11:56:59 +02:00
|
|
|
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/"))];
|
2026-10-05 06:20:18 +02:00
|
|
|
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;
|
2026-10-07 11:56:59 +02:00
|
|
|
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)
|
2026-10-05 06:20:18 +02:00
|
|
|
{
|
|
|
|
|
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
2026-10-07 11:56:59 +02:00
|
|
|
file.Size.GetFileSize(Localizer), ProfileForm.MaximumPictureSize.GetFileSize(Localizer)]);
|
2026-10-05 06:20:18 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 11:56:59 +02:00
|
|
|
// 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 };
|
2026-10-05 06:20:18 +02:00
|
|
|
try
|
|
|
|
|
{
|
2026-10-07 11:56:59 +02:00
|
|
|
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())}";
|
2026-10-05 06:20:18 +02:00
|
|
|
}
|
|
|
|
|
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]);
|
2026-10-07 11:56:59 +02:00
|
|
|
return;
|
2026-10-05 06:20:18 +02:00
|
|
|
}
|
2026-10-07 11:56:59 +02:00
|
|
|
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."]);
|
2026-10-05 06:20:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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."]);
|
|
|
|
|
}
|
|
|
|
|
}
|