2026-10-06 16:47:51 +02:00
@using System.Text.RegularExpressions
2026-10-04 11:03:14 +02:00
@inherits LocalizableComponentBase
@* A new post or a reply, as the current persona. Through the Mastodon API it is plain text with a content warning, a
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
2026-10-05 03:53:51 +02:00
this device's position and never federated; a group post goes to the group. Both of those go through /clientapi.
2026-10-05 04:42:42 +02:00
A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those.
2026-10-07 11:56:59 +02:00
It can hold a poll instead of media, quote a post (Quoting), or be the persona's own post edited (Editing: its words,
warning, media and their descriptions change; its visibility and poll stay). A file is uploaded as soon as it is
picked, streamed from the browser's file, and shows its state (uploading, processing, ready, failed with Retry); its
id is kept, so a post that fails never uploads it again. What the server takes (types, sizes) comes from it. *@
2026-10-04 11:03:14 +02:00
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
<DataAnnotationsValidator />
@if (form.HasContentWarning)
{
<DInput TValue="string" class="w-full rounded-t-[1.4rem] rounded-b-lg" maxlength="500" autocomplete="off"
placeholder="@Localizer["Content warning"]" @bind-Value="form.SpoilerText">
<Prefix><i class="ion-md-warning text-warning"></i></Prefix>
</DInput>
}
2026-10-06 23:15:54 +02:00
<div class="flex flex-col gap-1">
<DTextarea @ref="textarea" TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
2026-10-04 11:03:14 +02:00
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
2026-10-05 04:42:42 +02:00
placeholder="@Placeholder" rows="3" data-compose />
2026-10-06 23:15:54 +02:00
@* under the words, not over their last line *@
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
2026-10-04 11:03:14 +02:00
</div>
2026-10-06 16:47:51 +02:00
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
{
<div class="flex flex-wrap gap-2" data-suggestions>
@foreach (var account in suggestedAccounts)
{
2026-10-06 23:15:54 +02:00
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
2026-10-06 16:47:51 +02:00
@* alt="": the handle is next to it. *@
<img alt="" class="h-4 w-4 flex-none rounded-full object-cover" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="truncate">@("@" + account.Acct)</span>
</DButton>
}
@foreach (var tag in suggestedTags)
{
2026-10-06 23:15:54 +02:00
<DButton Size="DSize.Xs" KeepFocus="true" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
2026-10-06 16:47:51 +02:00
}
</div>
}
2026-10-04 11:03:14 +02:00
2026-10-05 04:42:42 +02:00
@if (Quoting is not null)
{
<Content Post="FeedPost.Of(Quoting)" Compact="true" CssContainer="opacity-90" />
}
@if (form.HasPoll)
{
<div class="flex flex-col gap-2 rounded-xl p-3 neo-inset neo-2xs" data-poll-editor>
@for (var i = 0; i < form.PollOptions.Count; i++)
{
var index = i;
<div class="flex items-center gap-2">
<input type="text" class="input input-sm flex-1" maxlength="100" autocomplete="off" placeholder="@Localizer["Choice {0}", index + 1]"
value="@form.PollOptions[index]" @onchange="@((ChangeEventArgs e) => form.PollOptions[index] = e.Value?.ToString())" />
@if (form.PollOptions.Count > 2)
{
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.RemoveAt(index)" title="@Localizer["Remove"]"><i class="ion-md-close"></i></DButton>
}
</div>
}
<div class="flex flex-wrap items-center gap-2">
@if (form.PollOptions.Count < 4)
{
<DButton Size="DSize.Xs" OnClick="() => form.PollOptions.Add(string.Empty)"><i class="ion-md-add"></i><span>@Localizer["Add a choice"]</span></DButton>
}
<DSelect TValue="int" Size="DSize.Xs" class="w-auto" @bind-Value="form.PollExpiresIn" aria-label="@Localizer["Poll length"]">
@foreach (var (seconds, label) in PollLengths)
{
<option value="@seconds">@label</option>
}
</DSelect>
<label class="label text-xs">
<input type="checkbox" class="checkbox checkbox-xs" checked="@form.PollMultiple" @onchange="@((ChangeEventArgs e) => form.PollMultiple = e.Value is true)" />
<span>@Localizer["Several choices"]</span>
</label>
</div>
</div>
}
2026-10-04 11:03:14 +02:00
@if (form.Media.Count > 0)
{
2026-10-07 11:56:59 +02:00
<div class="flex flex-col gap-2" data-compose-media>
2026-10-04 11:03:14 +02:00
@foreach (var media in form.Media)
{
2026-10-07 11:56:59 +02:00
<div class="flex w-full items-center gap-3 rounded-xl p-2 md:p-3 neo-raised neo-2xs" data-upload-state="@media.State.ToString().ToLowerInvariant()">
@if (media.Preview is { Length: > 0 } preview)
2026-10-04 11:03:14 +02:00
{
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
}
else
{
2026-10-07 11:56:59 +02:00
<i class="text-2xl @(media.ContentType?.StartsWith("video/") == true ? "ion-md-videocam" : media.ContentType?.StartsWith("audio/") == true ? "ion-md-musical-notes" : "ion-md-image")"></i>
2026-10-04 11:03:14 +02:00
}
<div class="flex flex-col flex-1 min-w-0 gap-1">
2026-10-07 11:56:59 +02:00
<p class="text-xs break-all"><b>@media.FileName</b>@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)</p>
<p class="inline-flex items-center gap-2 text-xs">
@switch (media.State)
{
case UploadState.Uploading:
<DLoading Size="DSize.Xs" LabelText="@Localizer["Uploading"]" /><span>@Localizer["Uploading"]</span>
break;
case UploadState.Processing:
<DLoading Size="DSize.Xs" LabelText="@Localizer["Processing"]" /><span>@Localizer["Processing"]</span>
break;
case UploadState.Failed:
<i class="ion-md-alert text-error"></i><span class="text-error break-words">@media.Error</span>
@if (media.File is not null)
{
<DButton Size="DSize.Xs" OnClick="() => Retry(media)" data-upload-retry>@Localizer["Retry"]</DButton>
}
break;
default:
<i class="ion-md-checkmark text-success"></i><span>@Localizer["Ready"]</span>
break;
}
</p>
<DTextarea TValue="string" @bind-Value="media.AltText" @bind-Value:after="() => altTextConfirmed = false" Size="DSize.Sm"
class="@(AskingAltText && Undescribed(media) ? "w-full rounded-lg! textarea-warning" : "w-full rounded-lg!")" autocomplete="off" maxlength="1500"
placeholder="@Localizer["Alternative text"]" rows="1" data-alt-text />
2026-10-04 11:03:14 +02:00
</div>
2026-10-07 11:56:59 +02:00
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]">
2026-10-04 11:03:14 +02:00
<i class="ion-md-trash text-base text-error"></i>
</DButton>
</div>
}
</div>
2026-10-07 11:56:59 +02:00
@if (AskingAltText)
{
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" class="text-sm" data-alt-reminder>
<span>@Localizer["{0} of the pictures have no description, which people who can't see them rely on.", form.Media.Count(Undescribed)]</span>
<div class="flex flex-wrap gap-2">
<DButton Size="DSize.Sm" OnClick="() => askingAltText = false">@Localizer["Describe them"]</DButton>
<DButton Size="DSize.Sm" OnClick="PublishAnyway" data-alt-publish-anyway>@Localizer["Publish anyway"]</DButton>
</div>
</DAlert>
}
2026-10-04 11:03:14 +02:00
}
@if (form.IsLocated)
{
<div class="flex flex-col gap-1 rounded-xl p-3 neo-inset neo-2xs text-xs">
<span class="inline-flex gap-2 items-center">
<i class="ion-md-locate text-primary"></i>
@if (form.Latitude is null)
{
<span>@Localizer["Asking this device for its position…"]</span>
}
else
{
<span>@Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToString("0")]</span>
}
</span>
<DRange TValue="double" Size="DSize.Xs" Min="1" Max="50" Step="1" @bind-Value="form.RangeKm" />
</div>
}
<ErrorViewer AdditionalError="@error" AdditionalErrors="fileErrors" />
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
2026-10-05 04:42:42 +02:00
@if (!form.IsLocated && GroupId is null && Editing is null)
2026-10-04 11:03:14 +02:00
{
2026-10-06 23:15:54 +02:00
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" SheetOnNarrow="true"
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"
aria-label="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
2026-10-04 11:03:14 +02:00
<Trigger>
2026-10-06 23:15:54 +02:00
<i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
2026-10-04 11:03:14 +02:00
</Trigger>
<ChildContent>
@foreach (var messageType in Enum.GetValues<MessageType>())
{
2026-10-06 23:15:54 +02:00
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)">
<i class="@messageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
<span>@Localizer[messageType.ToString()]</span>
2026-10-04 11:03:14 +02:00
</DButton>
}
</ChildContent>
</DDropdown>
}
2026-10-05 07:06:12 +02:00
@if (!form.IsLocated && GroupId is null && Editing is null)
{
@* the language the post is written in, as each language names itself *@
<DSelect TValue="string" Size="DSize.Sm" class="w-auto" @bind-Value="form.Language" title="@Localizer["Language"]" data-compose-language>
@foreach (var (languageCode, languageName) in Languages.All)
{
<option value="@languageCode">@languageName</option>
}
</DSelect>
}
2026-10-04 11:03:14 +02:00
<DButton Size="DSize.Sm" Active="form.HasContentWarning" OnClick="() => form.HasContentWarning = !form.HasContentWarning" title="@Localizer["Content warning"]">
<i class="ion-md-warning text-base"></i>
</DButton>
2026-10-07 11:56:59 +02:00
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
2026-10-04 11:03:14 +02:00
{
@* A label styled as a button opens the hidden file input. *@
<label class="btn btn-sm @(form.Media.Count >= MaximumFileCount ? "btn-disabled" : null)" title="@Localizer["Attach"]">
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
2026-10-07 11:56:59 +02:00
disabled="@(form.Media.Count >= MaximumFileCount)" data-attach />
2026-10-04 11:03:14 +02:00
<i class="ion-md-attach text-base"></i>
@if (form.Media.Count != 0)
{
<span>+@form.Media.Count</span>
}
</label>
<DSelect TValue="MediaType" Size="DSize.Sm" class="w-auto" Value="form.MediaType" ValueChanged="v => form.MediaType = v"
ValueExpression="() => form.MediaType" disabled="@(form.Media.Count > 0)">
<Prefix><i class="@form.MediaType.GetMediaTypeIcon()"></i></Prefix>
<ChildContent>
@foreach (var mediaType in new[] { MediaType.Images, MediaType.Video, MediaType.Audio })
{
<option value="@mediaType">@Localizer[mediaType.ToString()]</option>
}
</ChildContent>
</DSelect>
}
2026-10-05 04:42:42 +02:00
@if (!form.IsLocated && GroupId is null && Editing is null && form.Media.Count == 0)
{
<DButton Size="DSize.Sm" Active="form.HasPoll" OnClick="() => form.HasPoll = !form.HasPoll" title="@Localizer["Poll"]" data-poll-toggle>
<i class="ion-md-stats text-base"></i>
</DButton>
}
@if (InReplyTo is null && GroupId is null && Editing is null && Quoting is null)
2026-10-04 11:03:14 +02:00
{
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
<i class="ion-md-locate text-base"></i>
</DButton>
}
2026-10-05 03:53:51 +02:00
2026-10-05 04:42:42 +02:00
@if (!form.IsLocated && GroupId is null && Editing is null)
2026-10-05 03:53:51 +02:00
{
<DButton Size="DSize.Sm" Active="form.IsScheduled" OnClick="ToggleScheduled" title="@Localizer["Publish later"]" data-schedule>
<i class="ion-md-time text-base"></i>
</DButton>
}
2026-10-04 11:03:14 +02:00
</div>
<DButton Size="DSize.Sm" Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isPublishing || (form.IsLocated && form.Latitude is null)">
@if (isPublishing)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
2026-10-05 04:42:42 +02:00
<span>@SubmitLabel</span>
2026-10-05 03:53:51 +02:00
<i class="@(form.IsScheduled ? "ion-md-time" : "ion-md-send")"></i>
2026-10-04 11:03:14 +02:00
</DButton>
</div>
2026-10-05 03:53:51 +02:00
@if (form.IsScheduled && !form.IsLocated && GroupId is null)
{
<div class="flex flex-wrap items-center gap-2 text-sm">
<label class="flex flex-wrap items-center gap-2">
<span>@Localizer["Publish it on"]</span>
<DInput TValue="string" type="datetime-local" Size="DSize.Sm" class="w-auto" @bind-Value="form.ScheduledAt" autocomplete="off"
min="@DateTime.Now.AddMinutes(6).ToString("yyyy-MM-ddTHH:mm")" data-schedule-at />
</label>
<a class="link text-xs" href="scheduled">@Localizer["Scheduled posts"]</a>
</div>
}
2026-10-04 11:03:14 +02:00
</EditForm>
@code {
[Inject] StatusService StatusService { get; set; }
2026-10-07 11:56:59 +02:00
[Inject] MediaService MediaService { get; set; }
2026-10-06 16:47:51 +02:00
[Inject] AccountService AccountService { get; set; }
2026-10-04 11:03:14 +02:00
[Inject] ILoggingService Logger { get; set; }
[Inject] IJSRuntime JS { get; set; }
/// <summary>The status this answers; the reply starts with its visibility (never wider) and content warning.</summary>
[Parameter] public Status InReplyTo { get; set; }
/// <summary>The community or circle the post is for.</summary>
[Parameter] public string GroupId { get; set; }
2026-10-05 04:42:42 +02:00
/// <summary>The post this one quotes (shown under the words).</summary>
[Parameter] public Status Quoting { get; set; }
/// <summary>The persona's own post being edited: its words come from /source, and saving replaces them.</summary>
[Parameter] public Status Editing { get; set; }
2026-10-04 11:03:14 +02:00
/// <summary>The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API).</summary>
[Parameter] public EventCallback<FeedPost> OnPublished { get; set; }
ComposeForm form = new();
List<string> fileErrors = [];
2026-10-06 16:47:51 +02:00
// what the words end with while a mention or a hashtag is being written: its sign and what follows it
static readonly Regex Typing = new(@"(?:^|\s)([@#])([\p{L}\p{N}_][\p{L}\p{N}_.\-]*(?:@[\p{L}\p{N}.\-]*)?)$", RegexOptions.Compiled);
List<Account> suggestedAccounts = [];
List<StatusTag> suggestedTags = [];
// the newest lookup: an older one's answer comes too late to show
int suggesting;
2026-10-04 11:03:14 +02:00
string error;
bool isPublishing;
int ContentLength => form.Content?.Length ?? 0;
2026-10-06 16:47:51 +02:00
// what is being written, once typing pauses, among the accounts and hashtags this server knows
async Task Typed()
{
var typed = Typing.Match(form.Content ?? "");
var lookup = ++suggesting;
if (!typed.Success || typed.Groups[2].Length < 2)
{
suggestedAccounts = [];
suggestedTags = [];
return;
}
await Task.Delay(300);
if (lookup != suggesting)
return;
var hashtags = typed.Groups[1].Value == "#";
var found = await AccountService.Suggest(typed.Groups[2].Value, hashtags);
if (lookup != suggesting)
return;
suggestedAccounts = hashtags ? [] : [.. found.Accounts.Take(5)];
suggestedTags = hashtags ? [.. found.Hashtags.Take(5)] : [];
}
// the mention or hashtag being written, written out
void Complete(string written)
{
var typed = Typing.Match(form.Content ?? "");
suggesting++;
suggestedAccounts = [];
suggestedTags = [];
if (!typed.Success)
return;
form.Content = form.Content[..typed.Groups[1].Index] + written + " ";
2026-10-06 23:15:54 +02:00
focusPending = true;
2026-10-06 16:47:51 +02:00
}
2026-10-05 04:42:42 +02:00
string Placeholder => Editing is not null ? Localizer["Edit your post"]
: Quoting is not null ? Localizer["Say something about it"]
: InReplyTo is null ? Localizer["What's happening?"]
: Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct];
string SubmitLabel => Editing is not null ? Localizer["Save"]
: form.IsScheduled ? Localizer["Schedule"]
: Quoting is not null ? Localizer["Quote"]
: Localizer[InReplyTo is null ? "Post" : "Reply"];
(int Seconds, string Label)[] PollLengths =>
[
(300, Localizer["5 minutes"]), (1800, Localizer["30 minutes"]), (3600, Localizer["1 hour"]), (21600, Localizer["6 hours"]),
(86400, Localizer["1 day"]), (259200, Localizer["3 days"]), (604800, Localizer["7 days"])
];
2026-10-04 11:03:14 +02:00
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
2026-10-07 11:56:59 +02:00
// what the server takes: its types and sizes (/api/v2/instance), its defaults until they are read
MediaConfiguration limits = new();
// before publishing pictures without a description, asked once (the reader's setting); "Publish anyway" goes on
bool askingAltText;
bool altTextConfirmed;
bool AskingAltText => askingAltText && form.Media.Any(Undescribed);
static bool Undescribed(UploadMedia media) => media.ContentType?.StartsWith("image/") == true && string.IsNullOrWhiteSpace(media.AltText);
long MaximumFileSize(string type) => type.StartsWith("image/") ? limits.ImageSizeLimit : limits.VideoSizeLimit;
bool OfKind(string type) => form.MediaType switch
2026-10-04 11:03:14 +02:00
{
2026-10-07 11:56:59 +02:00
MediaType.Images => type.StartsWith("image/"),
MediaType.Video => type.StartsWith("video/"),
_ => type.StartsWith("audio/")
2026-10-04 11:03:14 +02:00
};
2026-10-07 11:56:59 +02:00
// the server's types of the kind chosen, and the extensions a file picker may know them by
string AcceptedFileTypes => string.Join(",", limits.SupportedMimeTypes.Where(OfKind).Concat(Extensions.Where(e => OfKind(e.Value)).Select(e => e.Key)));
static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
2026-10-04 11:03:14 +02:00
{
2026-10-07 11:56:59 +02:00
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
[".avif"] = "image/avif", [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".mov"] = "video/quicktime", [".webm"] = "video/webm",
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/ogg", [".wav"] = "audio/wav",
[".flac"] = "audio/flac", [".m4a"] = "audio/mp4"
2026-10-04 11:03:14 +02:00
};
2026-10-07 11:56:59 +02:00
// what a file is: the browser's word, else its extension's
static string TypeOf(IBrowserFile file) => file.ContentType is { Length: > 0 } type ? type.ToLowerInvariant()
: Extensions.GetValueOrDefault(Path.GetExtension(file.Name) ?? string.Empty);
2026-10-05 07:06:12 +02:00
// a new post starts in the language the persona chose in Settings, else in the reader's
string DefaultLanguage => CascadingState.Persona?.Source?.Language is { Length: > 0 } chosen ? chosen
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
2026-10-05 06:20:18 +02:00
// a new post starts with the visibility the persona chose in Settings
MessageType DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
{
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
};
2026-10-06 23:15:54 +02:00
DTextarea<string> textarea;
// the words take the focus when the composer opens, and again after a suggestion is picked
bool focusPending = true;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!focusPending)
return;
focusPending = false;
await textarea.Element.FocusAsync();
}
2026-10-05 04:42:42 +02:00
protected override async Task OnInitializedAsync()
2026-10-04 11:03:14 +02:00
{
form.GroupId = GroupId;
2026-10-05 06:20:18 +02:00
form.MessageType = DefaultVisibility;
2026-10-05 07:06:12 +02:00
form.Language = DefaultLanguage;
2026-10-07 11:56:59 +02:00
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
2026-10-05 04:42:42 +02:00
form.QuotedStatusId = Quoting?.Id;
2026-10-07 11:56:59 +02:00
limits = await MediaService.Limits();
2026-10-05 04:42:42 +02:00
if (Editing is not null)
{
var source = await StatusService.Source(Editing.Id);
form.Content = source?.Text;
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
form.SpoilerText = source?.SpoilerText;
2026-10-07 11:56:59 +02:00
// the post as it is: its language, its sensitive flag and its media, which the edit sends back
form.Language = Editing.Language ?? form.Language;
form.Sensitive = Editing.Sensitive;
form.Media = [.. Editing.MediaAttachments.Select(m => new UploadMedia
{
Id = m.Id,
State = UploadState.Ready,
Posted = true,
AltText = m.Description,
SentAltText = m.Description,
Preview = m.Type is "image" or "gifv" or "video" ? m.PreviewUrl : null,
ContentType = m.Type switch { "image" => "image/jpeg", "video" or "gifv" => "video/mp4", "audio" => "audio/mpeg", _ => null },
FileName = Path.GetFileName(m.Url ?? string.Empty)
})];
if (form.Media.FirstOrDefault()?.ContentType is { } kind)
form.MediaType = kind.StartsWith("video/") ? MediaType.Video : kind.StartsWith("audio/") ? MediaType.Audio : MediaType.Images;
2026-10-05 04:42:42 +02:00
}
IsLoading = false;
2026-10-04 11:03:14 +02:00
if (InReplyTo is null)
return;
form.InReplyToId = InReplyTo.Id;
form.MessageType = InReplyTo.Visibility switch
{
"direct" => MessageType.Direct,
"private" => MessageType.FollowersOnly,
"unlisted" => MessageType.Unlisted,
_ => MessageType.Public
};
2026-10-05 07:06:12 +02:00
// a reply is written in its thread's language, as a rule
form.Language = InReplyTo.Language is { Length: 2 } language && Languages.All.Any(l => l.Code == language) ? language : form.Language;
2026-10-04 11:03:14 +02:00
form.HasContentWarning = InReplyTo.SpoilerText is { Length: > 0 };
form.SpoilerText = InReplyTo.SpoilerText;
var mentions = InReplyTo.Mentions.Select(m => m.Acct).Prepend(InReplyTo.Account?.Acct)
.Where(acct => acct is not null && acct != CascadingState.Persona?.Acct).Distinct().Select(acct => "@" + acct);
form.Content = string.Join(' ', mentions) is { Length: > 0 } prefix ? prefix + " " : null;
}
void SetMessageType(MessageType messageType)
{
form.MessageType = messageType;
form.IsScopeOptionsOpen = false;
}
2026-10-05 03:53:51 +02:00
// a scheduled post starts an hour ahead, which the reader moves as it likes
void ToggleScheduled()
{
form.IsScheduled = !form.IsScheduled;
if (form.IsScheduled && string.IsNullOrEmpty(form.ScheduledAt))
form.ScheduledAt = DateTime.Now.AddHours(1).ToString("yyyy-MM-ddTHH:mm");
}
2026-10-04 11:03:14 +02:00
async Task ToggleLocated()
{
form.IsLocated = !form.IsLocated;
if (!form.IsLocated || form.Latitude is not null)
return;
var position = await JS.InvokeAsync<GeoPosition>("feedPosition");
if (position?.Error is { Length: > 0 } positionError)
{
form.IsLocated = false;
error = Localizer["This device did not give its position: {0}", positionError];
return;
}
form.Latitude = position?.Latitude;
form.Longitude = position?.Longitude;
}
async Task OnFileChange(InputFileChangeEventArgs eventArgs)
{
fileErrors = [];
if (eventArgs.FileCount + form.Media.Count > MaximumFileCount)
{
fileErrors.Add(Localizer["The maximum number of files accepted is {0}, but {1} were supplied.", MaximumFileCount, form.Media.Count + eventArgs.FileCount]);
return;
}
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
{
2026-10-07 11:56:59 +02:00
var type = TypeOf(file);
if (type is null || !limits.SupportedMimeTypes.Contains(type) || !OfKind(type))
{
fileErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
continue;
}
if (file.Size > MaximumFileSize(type))
2026-10-04 11:03:14 +02:00
{
fileErrors.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), MaximumFileSize(type).GetFileSize(Localizer)]);
2026-10-04 11:03:14 +02:00
continue;
}
2026-10-07 11:56:59 +02:00
var media = new UploadMedia { File = file, FileName = file.Name, ContentType = type, Size = file.Size };
if (type.StartsWith("image/"))
media.Preview = await Thumbnail(file);
form.Media.Add(media);
altTextConfirmed = false;
Start(media);
2026-10-04 11:03:14 +02:00
}
}
2026-10-07 11:56:59 +02:00
// a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't
async Task<string> Thumbnail(IBrowserFile file)
2026-10-04 11:03:14 +02:00
{
try
{
2026-10-07 11:56:59 +02:00
var small = await file.RequestImageFileAsync("image/jpeg", 320, 320);
await using var stream = small.OpenReadStream(2 * 1024 * 1024);
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer);
return $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
2026-10-04 11:03:14 +02:00
}
catch (Exception ex)
{
2026-10-07 11:56:59 +02:00
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(Composer)}.{nameof(Thumbnail)}");
2026-10-04 11:03:14 +02:00
return default;
}
}
2026-10-07 11:56:59 +02:00
void Start(UploadMedia media)
{
media.Cancel = new();
media.State = UploadState.Uploading;
media.Error = default;
media.Done = Upload(media, media.Cancel.Token);
}
// uploads it, then follows what the server still processes (audio and video) until it is ready or refused
async Task Upload(UploadMedia media, CancellationToken cancellationToken)
{
var uploaded = await MediaService.Upload(media, MaximumFileSize(media.ContentType), cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
if (uploaded.IsInvalid)
{
media.State = UploadState.Failed;
media.Error = uploaded.ErrorMessage;
await InvokeAsync(StateHasChanged);
return;
}
var attachment = (MediaAttachment)uploaded.Data;
media.Id = attachment.Id;
media.SentAltText = attachment.Description;
media.State = attachment.Url is null ? UploadState.Processing : UploadState.Ready;
await InvokeAsync(StateHasChanged);
while (media.State == UploadState.Processing && !cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(2000, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
var processed = await MediaService.Get(media.Id, cancellationToken);
if (cancellationToken.IsCancellationRequested)
return;
if (processed.IsInvalid)
{
media.State = UploadState.Failed;
media.Error = processed.ErrorMessage;
}
else if (processed.Data is MediaAttachment)
media.State = UploadState.Ready;
await InvokeAsync(StateHasChanged);
}
}
// a failed upload again, from the same file (the browser can still read it unless files were picked since)
void Retry(UploadMedia media) => Start(media);
void Remove(UploadMedia media)
{
media.Cancel?.Cancel();
form.Media.Remove(media);
}
// the reader chose to publish its pictures without descriptions
async Task PublishAnyway()
{
altTextConfirmed = true;
askingAltText = false;
await Publish();
}
public override async ValueTask DisposeAsync()
{
foreach (var media in form.Media)
media.Cancel?.Cancel();
await base.DisposeAsync();
}
2026-10-04 11:03:14 +02:00
async Task Publish()
{
error = default;
if (string.IsNullOrWhiteSpace(form.Content) && form.Media.Count == 0)
{
error = Localizer["Missing content, either message or media"];
return;
}
2026-10-05 04:42:42 +02:00
if (form.HasPoll && form.PollOptions.Count(o => !string.IsNullOrWhiteSpace(o)) < 2)
{
error = Localizer["A poll needs two choices at least."];
return;
}
2026-10-07 11:56:59 +02:00
if (!altTextConfirmed && CascadingState.PublicCacheData.PageSettings.RemindAltText && form.Media.Any(Undescribed))
{
askingAltText = true;
return;
}
// what is still uploading or processing is waited for
if (form.Media.Any(m => m.State is UploadState.Uploading or UploadState.Processing))
{
isPublishing = true;
await Task.WhenAll(form.Media.Select(m => m.Done));
isPublishing = false;
}
2026-10-05 04:42:42 +02:00
if (Editing is not null)
{
isPublishing = true;
var edited = await StatusService.Edit(Editing.Id, form);
isPublishing = false;
if (edited.IsInvalid)
{
error = edited.ErrorMessage;
return;
}
Toast.AddResponseSuccess(Localizer["Saved."]);
await OnPublished.InvokeAsync(FeedPost.Of((Status)edited.Data));
return;
}
2026-10-04 11:03:14 +02:00
if (CascadingState.PersonaId is null)
{
error = Localizer["Choose a persona first."];
return;
}
2026-10-05 03:53:51 +02:00
if (form.IsScheduled && form.ScheduledAtUtc is not { } at)
{
error = Localizer["Choose when to publish it."];
return;
}
if (form.ScheduledAtUtc < DateTime.UtcNow.AddMinutes(5))
{
error = Localizer["A post is scheduled at least five minutes ahead."];
return;
}
2026-10-04 11:03:14 +02:00
isPublishing = true;
var result = await StatusService.Publish(form, CascadingState.PersonaId);
isPublishing = false;
if (result.IsInvalid)
{
error = result.ErrorMessage;
return;
}
2026-10-05 03:53:51 +02:00
if (result.Data is ScheduledStatus scheduled)
{
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
2026-10-05 07:06:12 +02:00
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
2026-10-05 03:53:51 +02:00
await OnPublished.InvokeAsync(null);
return;
}
2026-10-04 11:03:14 +02:00
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
var published = result.Data as FeedPost;
2026-10-05 07:06:12 +02:00
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
2026-10-04 11:03:14 +02:00
await OnPublished.InvokeAsync(published);
}
sealed class GeoPosition
{
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public double? Accuracy { get; set; }
public string Error { get; set; }
}
}