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
709 lines
28 KiB
Plaintext
709 lines
28 KiB
Plaintext
@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. *@
|
|
<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>
|
|
}
|
|
<div class="flex flex-col gap-1">
|
|
<DTextarea @ref="textarea" TValue="string" @bind-Value="form.Content" @bind-Value:after="Typed" Immediate="true"
|
|
class="@(form.HasContentWarning ? "w-full rounded-b-[1.4rem] rounded-t-lg" : "w-full rounded-[1.4rem]")" maxlength="@ComposeForm.MaxContentLength" autocomplete="off"
|
|
placeholder="@Placeholder" rows="3" data-compose />
|
|
@* under the words, not over their last line *@
|
|
<span class="self-end text-xs opacity-50 tabular-nums">@ContentLength/@ComposeForm.MaxContentLength</span>
|
|
</div>
|
|
@if (suggestedAccounts.Count > 0 || suggestedTags.Count > 0)
|
|
{
|
|
<div class="flex flex-wrap gap-2" data-suggestions>
|
|
@foreach (var account in suggestedAccounts)
|
|
{
|
|
<DButton Size="DSize.Xs" class="max-w-full" KeepFocus="true" OnClick="@(() => Complete("@" + account.Acct))" data-suggestion="@("@" + account.Acct)">
|
|
@* 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)
|
|
{
|
|
<DButton Size="DSize.Xs" KeepFocus="true" OnClick="@(() => Complete("#" + tag.Name))" data-suggestion="@("#" + tag.Name)">#@tag.Name</DButton>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@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>
|
|
}
|
|
|
|
@if (form.Media.Count > 0)
|
|
{
|
|
<div class="flex flex-col gap-2" data-compose-media>
|
|
@foreach (var media in form.Media)
|
|
{
|
|
<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)
|
|
{
|
|
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
|
}
|
|
else
|
|
{
|
|
<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>
|
|
}
|
|
<div class="flex flex-col flex-1 min-w-0 gap-1">
|
|
<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 />
|
|
</div>
|
|
<DButton Size="DSize.Sm" class="self-start" OnClick="() => Remove(media)" title="@Localizer["Delete"]" aria-label="@Localizer["Delete"]">
|
|
<i class="ion-md-trash text-base text-error"></i>
|
|
</DButton>
|
|
</div>
|
|
}
|
|
</div>
|
|
@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>
|
|
}
|
|
}
|
|
|
|
@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">
|
|
@if (!form.IsLocated && GroupId is null && Editing is null)
|
|
{
|
|
<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()]]">
|
|
<Trigger>
|
|
<i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
|
|
</Trigger>
|
|
<ChildContent>
|
|
@foreach (var messageType in Enum.GetValues<MessageType>())
|
|
{
|
|
<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>
|
|
</DButton>
|
|
}
|
|
</ChildContent>
|
|
</DDropdown>
|
|
}
|
|
|
|
@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>
|
|
}
|
|
|
|
<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>
|
|
|
|
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
|
|
{
|
|
@* 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"
|
|
disabled="@(form.Media.Count >= MaximumFileCount)" data-attach />
|
|
<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>
|
|
}
|
|
|
|
@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)
|
|
{
|
|
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
|
|
<i class="ion-md-locate text-base"></i>
|
|
</DButton>
|
|
}
|
|
|
|
@if (!form.IsLocated && GroupId is null && Editing is null)
|
|
{
|
|
<DButton Size="DSize.Sm" Active="form.IsScheduled" OnClick="ToggleScheduled" title="@Localizer["Publish later"]" data-schedule>
|
|
<i class="ion-md-time text-base"></i>
|
|
</DButton>
|
|
}
|
|
</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"]" />
|
|
}
|
|
<span>@SubmitLabel</span>
|
|
<i class="@(form.IsScheduled ? "ion-md-time" : "ion-md-send")"></i>
|
|
</DButton>
|
|
</div>
|
|
|
|
@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>
|
|
}
|
|
</EditForm>
|
|
|
|
@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; }
|
|
|
|
/// <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; }
|
|
/// <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; }
|
|
/// <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 = [];
|
|
// 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;
|
|
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<string, string> 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<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();
|
|
}
|
|
|
|
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<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))
|
|
{
|
|
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<string> 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; }
|
|
}
|
|
}
|