@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 and warning change, its media, visibility and poll stay). *@ @if (form.HasContentWarning) { }
@ContentLength/@ComposeForm.MaxContentLength
@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.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image")) { @media.AltText } else { }

@media.FileName · @media.ContentType · @media.Size.GetFileSize(Localizer)

}
} @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()) { } } @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 && Editing 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] 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 = []; string error; bool isPublishing; int ContentLength => form.Content?.Length ?? 0; 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; long MaximumFileSize => form.MediaType switch { MediaType.Images => 16_777_216, MediaType.Audio => 41_943_040, _ => 99_614_720 }; string AcceptedFileTypes => form.MediaType switch { MediaType.Images => ".jpg,.jpeg,.png,.gif,.webp,.avif", MediaType.Video => ".mp4,.m4v,.webm,.mov", MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a", _ => default }; // 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 }; protected override async Task OnInitializedAsync() { form.GroupId = GroupId; form.MessageType = DefaultVisibility; form.Language = DefaultLanguage; form.QuotedStatusId = Quoting?.Id; 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; } 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)) { if (file.Size > MaximumFileSize) { fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]); continue; } if (await ReadFile(file) is { } media) form.Media.Add(media); else fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]); } } /// Reads a picked file into memory; null when the browser can't read it (logged). async Task ReadFile(IBrowserFile file, CancellationToken cancellationToken = default) { try { using var memoryStream = new MemoryStream(); await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken); await fileStream.CopyToAsync(memoryStream, cancellationToken); var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() }; if (file.ContentType.StartsWith("image")) media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}"; return media; } catch (Exception ex) { await Logger.ProcessError(ex, $"{nameof(Composer)}.{nameof(ReadFile)}:{file.Name}", cancellationToken); return default; } } 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 (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; } } }