300 lines
11 KiB
Plaintext
300 lines
11 KiB
Plaintext
@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. *@
|
||
|
|
<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="relative">
|
||
|
|
<DTextarea TValue="string" @bind-Value="form.Content" 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="@(InReplyTo is null ? Localizer["What's happening?"] : Localizer["Reply to {0}", "@" + InReplyTo.Account?.Acct])" rows="3" />
|
||
|
|
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
@if (form.Media.Count > 0)
|
||
|
|
{
|
||
|
|
<div class="flex flex-col gap-2">
|
||
|
|
@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">
|
||
|
|
@if (media.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
|
||
|
|
{
|
||
|
|
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
<i class="text-2xl @form.MediaType.GetMediaTypeIcon()"></i>
|
||
|
|
}
|
||
|
|
<div class="flex flex-col flex-1 min-w-0 gap-1">
|
||
|
|
<p class="text-xs break-all"><b>@media.FileName</b> · @media.ContentType · @media.Size.GetFileSize(Localizer)</p>
|
||
|
|
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
|
||
|
|
placeholder="@Localizer["Alternative text"]" rows="1" />
|
||
|
|
</div>
|
||
|
|
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]">
|
||
|
|
<i class="ion-md-trash text-base text-error"></i>
|
||
|
|
</DButton>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
|
||
|
|
@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)
|
||
|
|
{
|
||
|
|
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
|
||
|
|
ContentClass="mt-2 flex gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
|
||
|
|
<Trigger>
|
||
|
|
<i class="@form.MessageType.GetMessageTypeIcon() text-base"></i>
|
||
|
|
</Trigger>
|
||
|
|
<ChildContent>
|
||
|
|
@foreach (var messageType in Enum.GetValues<MessageType>())
|
||
|
|
{
|
||
|
|
<DButton Size="DSize.Sm" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)" title="@Localizer[messageType.ToString()]">
|
||
|
|
<i class="@messageType.GetMessageTypeIcon() text-base"></i>
|
||
|
|
</DButton>
|
||
|
|
}
|
||
|
|
</ChildContent>
|
||
|
|
</DDropdown>
|
||
|
|
}
|
||
|
|
|
||
|
|
<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)
|
||
|
|
{
|
||
|
|
@* 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)" />
|
||
|
|
<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 (InReplyTo is null && GroupId is null)
|
||
|
|
{
|
||
|
|
<DButton Size="DSize.Sm" Active="form.IsLocated" OnClick="ToggleLocated" title="@Localizer["Located post"]">
|
||
|
|
<i class="ion-md-locate 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>@Localizer[InReplyTo is null ? "Post" : "Reply"]</span>
|
||
|
|
<i class="ion-md-send"></i>
|
||
|
|
</DButton>
|
||
|
|
</div>
|
||
|
|
</EditForm>
|
||
|
|
|
||
|
|
@code {
|
||
|
|
[Inject] StatusService StatusService { 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 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 = [];
|
||
|
|
string error;
|
||
|
|
bool isPublishing;
|
||
|
|
|
||
|
|
int ContentLength => form.Content?.Length ?? 0;
|
||
|
|
|
||
|
|
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
|
||
|
|
};
|
||
|
|
|
||
|
|
protected override void OnInitialized()
|
||
|
|
{
|
||
|
|
IsLoading = false;
|
||
|
|
form.GroupId = GroupId;
|
||
|
|
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
|
||
|
|
};
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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))
|
||
|
|
{
|
||
|
|
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]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
|
||
|
|
async Task<UploadMedia> 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 (CascadingState.PersonaId is null)
|
||
|
|
{
|
||
|
|
error = Localizer["Choose a persona first."];
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
isPublishing = true;
|
||
|
|
var result = await StatusService.Publish(form, CascadingState.PersonaId);
|
||
|
|
isPublishing = false;
|
||
|
|
if (result.IsInvalid)
|
||
|
|
{
|
||
|
|
error = result.ErrorMessage;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
|
||
|
|
var published = result.Data as FeedPost;
|
||
|
|
form = new() { GroupId = GroupId };
|
||
|
|
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; }
|
||
|
|
}
|
||
|
|
}
|