@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
and warning change, its media, visibility and poll stay). *@
@if (form.HasContentWarning)
{
}
@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.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)
{
}
@code {
[Inject] StatusService StatusService { 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 + " ";
}
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; }
}
}