Settings, which held only the theme, now belong to the persona too: - its profile (update_credentials, a new PATCH in IHttpService): pictures, name, biography and fields as written, locked, bot, discoverable, indexable, and how its posts start (visibility, language, sensitive media, who may quote them); the composer starts at that visibility; - the root's personas, each used in turn, and a new one made; - the accounts it muted or blocked and the servers it blocked, each undone there, a server blocked from a pasted address or handle; - its follows, lists, blocks, mutes, blocked servers and bookmarks exported and imported as the CSV files Mastodon writes (PortabilityService): an import resolves each row through PrivaPub, one at a time, and can stop. Administration opens to moderators: reports, resolved there; and for administrators the servers blocked for everyone and the federation statistics (traffic by server, delivery success and latency, the crawler). The Direct tab pages conversations, badges the unread ones, and a card marks one read or takes it off the list; opening one reads it. A remote thread is read again 4 and 15 seconds after it opens, since PrivaPub then fetches the replies its servers hold. A thread its community locked shows a lock instead of a reply button, reactions stay visible below a content warning, and a searched handle opens its account's profile whatever else matched. The e2e tests cover each: the profile saved and read back, a persona made, servers blocked and imported, follows exported, an instance block and the statistics as the pasture's admin, a conversation read and removed, and a reply only Mastodon held appearing in its thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
452 lines
17 KiB
Plaintext
452 lines
17 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.
|
|
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). *@
|
|
<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="@Placeholder" rows="3" data-compose />
|
|
<span class="absolute text-xs opacity-50 right-3 bottom-2">@ContentLength/@ComposeForm.MaxContentLength</span>
|
|
</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">
|
|
@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 && Editing 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 && Editing 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)" />
|
|
<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] 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 = [];
|
|
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 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.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
|
|
};
|
|
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))
|
|
{
|
|
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 (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 };
|
|
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 };
|
|
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; }
|
|
}
|
|
}
|