@using System.Text.RegularExpressions @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 this device's position and never federated; a group post goes to the group. Both of those go through /clientapi. A Mastodon API post can wait for a time instead (scheduled_at, at least five minutes ahead); /scheduled lists those. 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. *@ @if (form.HasContentWarning) { }
@* under the words, not over their last line *@ @ContentLength/@ComposeForm.MaxContentLength
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0) {
@foreach (var account in suggestedAccounts) { @* alt="": the handle is next to it. *@ @("@" + account.Acct) } @foreach (var tag in suggestedTags) { #@tag.Name }
} @if (Quoting is not null) { } @if (form.HasPoll) {
@for (var i = 0; i < form.PollOptions.Count; i++) { var index = i;
@if (form.PollOptions.Count > 2) { }
}
@if (form.PollOptions.Count < 4) { @Localizer["Add a choice"] } @foreach (var (seconds, label) in PollLengths) { }
} @if (form.Media.Count > 0) {
@foreach (var media in form.Media) {
@if (media.Preview is { Length: > 0 } preview) { @media.AltText } else { }

@media.FileName@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)

@switch (media.State) { case UploadState.Uploading: @Localizer["Uploading"] break; case UploadState.Processing: @Localizer["Processing"] break; case UploadState.Failed: @media.Error @if (media.File is not null) { @Localizer["Retry"] } break; default: @Localizer["Ready"] break; }

}
@if (AskingAltText) { @Localizer["{0} of the pictures have no description, which people who can't see them rely on.", form.Media.Count(Undescribed)]
@Localizer["Describe them"] @Localizer["Publish anyway"]
} } @if (form.IsLocated) {
@if (form.Latitude is null) { @Localizer["Asking this device for its position…"] } else { @Localizer["Shown only to people within {0} km of here, never sent to other servers.", form.RangeKm.ToString("0")] }
}
@if (!form.IsLocated && GroupId is null && Editing is null) { @foreach (var messageType in Enum.GetValues()) { @Localizer[messageType.ToString()] } } @if (!form.IsLocated && GroupId is null && Editing is null) { @* the language the post is written in, as each language names itself *@ @foreach (var (languageCode, languageName) in Languages.All) { } } @if (!form.IsLocated && GroupId is null && !form.HasPoll) { @* A label styled as a button opens the hidden file input. *@ @foreach (var mediaType in new[] { MediaType.Images, MediaType.Video, MediaType.Audio }) { } } @if (!form.IsLocated && GroupId is null && Editing is null && form.Media.Count == 0) { } @if (InReplyTo is null && GroupId is null && Editing is null && Quoting is null) { } @if (!form.IsLocated && GroupId is null && Editing is null) { }
@if (isPublishing) { } @SubmitLabel
@if (form.IsScheduled && !form.IsLocated && GroupId is null) {
@Localizer["Scheduled posts"]
}
@code { [Inject] StatusService StatusService { get; set; } [Inject] MediaService MediaService { get; set; } [Inject] AccountService AccountService { get; set; } [Inject] ILoggingService Logger { get; set; } [Inject] IJSRuntime JS { get; set; } /// The status this answers; the reply starts with its visibility (never wider) and content warning. [Parameter] public Status InReplyTo { get; set; } /// The community or circle the post is for. [Parameter] public string GroupId { get; set; } /// The post this one quotes (shown under the words). [Parameter] public Status Quoting { get; set; } /// The persona's own post being edited: its words come from /source, and saving replaces them. [Parameter] public Status Editing { get; set; } /// The published post, to show it; a located or group post comes back as null (it is not on the Mastodon API). [Parameter] public EventCallback OnPublished { get; set; } ComposeForm form = new(); List fileErrors = []; // 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 suggestedAccounts = []; List suggestedTags = []; // the newest lookup: an older one's answer comes too late to show int suggesting; string error; bool isPublishing; int ContentLength => form.Content?.Length ?? 0; // 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 + " "; focusPending = true; } 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"]) ]; int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1; // 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 { MediaType.Images => type.StartsWith("image/"), MediaType.Video => type.StartsWith("video/"), _ => type.StartsWith("audio/") }; // 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 Extensions = new(StringComparer.OrdinalIgnoreCase) { [".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" }; // 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); // 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; // 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 }; DTextarea 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(); } protected override async Task OnInitializedAsync() { form.GroupId = GroupId; form.MessageType = DefaultVisibility; form.Language = DefaultLanguage; form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true; form.QuotedStatusId = Quoting?.Id; limits = await MediaService.Limits(); 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; // 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; } IsLoading = false; 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 }; // 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; 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; } // 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"); } async Task ToggleLocated() { form.IsLocated = !form.IsLocated; if (!form.IsLocated || form.Latitude is not null) return; var position = await JS.InvokeAsync("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)) { 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)) { fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer), MaximumFileSize(type).GetFileSize(Localizer)]); continue; } 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); } } // a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't async Task Thumbnail(IBrowserFile file) { try { 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())}"; } catch (Exception ex) { await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(Composer)}.{nameof(Thumbnail)}"); return default; } } 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(); } async Task Publish() { error = default; if (string.IsNullOrWhiteSpace(form.Content) && form.Media.Count == 0) { error = Localizer["Missing content, either message or media"]; return; } if (form.HasPoll && form.PollOptions.Count(o => !string.IsNullOrWhiteSpace(o)) < 2) { error = Localizer["A poll needs two choices at least."]; return; } 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; } 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; } if (CascadingState.PersonaId is null) { error = Localizer["Choose a persona first."]; return; } 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; } isPublishing = true; var result = await StatusService.Publish(form, CascadingState.PersonaId); isPublishing = false; if (result.IsInvalid) { error = result.ErrorMessage; return; } if (result.Data is ScheduledStatus scheduled) { Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]); form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage }; await OnPublished.InvokeAsync(null); return; } Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]); var published = result.Data as FeedPost; form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage }; 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; } } }