Media survive failures: uploaded when attached, the server's limits, alt text asked for
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
This commit is contained in:
1 parent
9d662f702b
commit
70cb389537
26 files changed
+877
-126
No files matched your search
@@ -274,6 +274,8 @@ supplies:
|
|||||||
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
||||||
PrivaPub redirects it).
|
PrivaPub redirects it).
|
||||||
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
||||||
|
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
|
||||||
|
`HttpClient`'s own is off.
|
||||||
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
|
- Failures never throw. They come back as responses carrying an invalid `WebResult`, with an `ErrorCode` from
|
||||||
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
`Models/FailureCodes.cs`; a cancelled request comes back as HTTP 410.
|
||||||
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
- Read failures with `ReadWebResult` (it understands `WebResult`, validation ProblemDetails and Mastodon's
|
||||||
@@ -309,8 +311,10 @@ supplies:
|
|||||||
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
|
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
|
||||||
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse
|
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse
|
||||||
post's downvotes next to its favourites, blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
|
post's downvotes next to its favourites, blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
|
||||||
watches the page for `img[data-blurhash]`), a picture's focal point (`object-position`), and an event's online
|
paints any `[data-blurhash]`: a picture's until it loads, again when its source changes, and for good behind the
|
||||||
link, participants and status.
|
sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and
|
||||||
|
status. A picture, video or sound that fails to load says "Media unavailable" with "Open original"
|
||||||
|
(`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`.
|
||||||
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
|
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
|
||||||
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
|
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
|
||||||
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
|
A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the
|
||||||
@@ -340,8 +344,21 @@ supplies:
|
|||||||
than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the
|
than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the
|
||||||
post opened in its thread shows whole.
|
post opened in its thread shows whole.
|
||||||
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
|
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
|
||||||
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and
|
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`); a post can hold
|
||||||
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
|
a poll instead of media.
|
||||||
|
- **Media are uploaded as soon as they are picked** (`Services/MediaService.cs`, `Models/UploadMedia.cs`):
|
||||||
|
- each is streamed from the browser's file, and only a 320 px thumbnail (`RequestImageFileAsync`) is kept to show it;
|
||||||
|
- each shows its state (`data-upload-state`: uploading, processing, ready, or failed with Retry);
|
||||||
|
- audio and video PrivaPub answers 202 for are followed (`GET /api/v1/media/:id`, 206 until ready);
|
||||||
|
- the ids are kept, so a post that fails never uploads its media again;
|
||||||
|
- the types and sizes accepted are PrivaPub's own (`/api/v2/instance`, `MediaService.Limits`);
|
||||||
|
- a description changed after the upload is PUT before the post.
|
||||||
|
- **Alt text:** publishing a picture without a description asks once (`data-alt-reminder`, "Publish anyway"), unless
|
||||||
|
the reader turned `PageSettings.RemindAltText` off in Settings → General.
|
||||||
|
- **Edits send the whole post, as Mastodon wants it:** its words and warning from `/source`, its own language and
|
||||||
|
sensitive flag, the media it keeps or adds (`media_ids`; removing them all is `[]`), and the descriptions changed
|
||||||
|
(`media_attributes`). Its visibility and poll stay.
|
||||||
|
- A new post's media are sensitive when the persona's "always mark media as sensitive" (`source.sensitive`) says so.
|
||||||
A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
|
A mention or hashtag being written at the end of the words is offered what PrivaPub already knows
|
||||||
(`/api/v2/search`, never resolving), once typing pauses.
|
(`/api/v2/search`, never resolving), once typing pauses.
|
||||||
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
|
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
|
||||||
@@ -355,7 +372,8 @@ supplies:
|
|||||||
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
|
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
|
||||||
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
|
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
|
||||||
the client then signs out), the persona's profile (`ProfileEditor`:
|
the client then signs out), the persona's profile (`ProfileEditor`:
|
||||||
`update_credentials` as multipart, its words as written from `Account.Source`, pictures, fields, locked, bot,
|
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
|
||||||
|
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
|
||||||
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
||||||
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
|
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
|
||||||
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
|
||||||
|
|||||||
+200
-36
@@ -5,8 +5,10 @@
|
|||||||
visibility and media (each uploaded first, with its alt text). A located post is text only, kept within its range of
|
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.
|
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.
|
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
|
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). *@
|
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">
|
<EditForm Model="form" OnValidSubmit="Publish" class="flex flex-col gap-3">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
@@ -83,29 +85,61 @@
|
|||||||
|
|
||||||
@if (form.Media.Count > 0)
|
@if (form.Media.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2" data-compose-media>
|
||||||
@foreach (var media in form.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">
|
<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.Base64Preview is { Length: > 0 } preview && media.ContentType.StartsWith("image"))
|
@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" />
|
<img alt="@media.AltText" class="object-cover rounded-lg max-h-20 max-w-24 flex-none" src="@preview" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="text-2xl @form.MediaType.GetMediaTypeIcon()"></i>
|
<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">
|
<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>
|
<p class="text-xs break-all"><b>@media.FileName</b>@(media.Size > 0 ? $" · {media.Size.GetFileSize(Localizer)}" : null)</p>
|
||||||
<DTextarea TValue="string" @bind-Value="media.AltText" Size="DSize.Sm" class="w-full rounded-lg!" autocomplete="off" maxlength="1500"
|
<p class="inline-flex items-center gap-2 text-xs">
|
||||||
placeholder="@Localizer["Alternative text"]" rows="1" />
|
@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>
|
</div>
|
||||||
<DButton Size="DSize.Sm" class="self-start" OnClick="() => form.Media.Remove(media)" title="@Localizer["Delete"]">
|
<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>
|
<i class="ion-md-trash text-base text-error"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</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)
|
@if (form.IsLocated)
|
||||||
@@ -165,12 +199,12 @@
|
|||||||
<i class="ion-md-warning text-base"></i>
|
<i class="ion-md-warning text-base"></i>
|
||||||
</DButton>
|
</DButton>
|
||||||
|
|
||||||
@if (!form.IsLocated && GroupId is null && Editing is null && !form.HasPoll)
|
@if (!form.IsLocated && GroupId is null && !form.HasPoll)
|
||||||
{
|
{
|
||||||
@* A label styled as a button opens the hidden file input. *@
|
@* 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"]">
|
<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"
|
<InputFile accept="@AcceptedFileTypes" class="hidden" multiple="@(form.MediaType is MediaType.Images)" OnChange="OnFileChange"
|
||||||
disabled="@(form.Media.Count >= MaximumFileCount)" />
|
disabled="@(form.Media.Count >= MaximumFileCount)" data-attach />
|
||||||
<i class="ion-md-attach text-base"></i>
|
<i class="ion-md-attach text-base"></i>
|
||||||
@if (form.Media.Count != 0)
|
@if (form.Media.Count != 0)
|
||||||
{
|
{
|
||||||
@@ -236,6 +270,7 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] StatusService StatusService { get; set; }
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
[Inject] MediaService MediaService { get; set; }
|
||||||
[Inject] AccountService AccountService { get; set; }
|
[Inject] AccountService AccountService { get; set; }
|
||||||
[Inject] ILoggingService Logger { get; set; }
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
[Inject] IJSRuntime JS { get; set; }
|
[Inject] IJSRuntime JS { get; set; }
|
||||||
@@ -317,21 +352,40 @@
|
|||||||
|
|
||||||
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
|
int MaximumFileCount => form.MediaType is MediaType.Images ? 4 : 1;
|
||||||
|
|
||||||
long MaximumFileSize => form.MediaType switch
|
// 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 => 16_777_216,
|
MediaType.Images => type.StartsWith("image/"),
|
||||||
MediaType.Audio => 41_943_040,
|
MediaType.Video => type.StartsWith("video/"),
|
||||||
_ => 99_614_720
|
_ => type.StartsWith("audio/")
|
||||||
};
|
};
|
||||||
|
|
||||||
string AcceptedFileTypes => form.MediaType switch
|
// 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)
|
||||||
{
|
{
|
||||||
MediaType.Images => ".jpg,.jpeg,.png,.gif,.webp,.avif",
|
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
|
||||||
MediaType.Video => ".mp4,.m4v,.webm,.mov",
|
[".avif"] = "image/avif", [".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".mov"] = "video/quicktime", [".webm"] = "video/webm",
|
||||||
MediaType.Audio => ".mp3,.ogg,.oga,.opus,.wav,.flac,.m4a",
|
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/ogg", [".wav"] = "audio/wav",
|
||||||
_ => default
|
[".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
|
// 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
|
string DefaultLanguage => CascadingState.Persona?.Source?.Language is { Length: > 0 } chosen ? chosen
|
||||||
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||||
@@ -361,13 +415,31 @@
|
|||||||
form.GroupId = GroupId;
|
form.GroupId = GroupId;
|
||||||
form.MessageType = DefaultVisibility;
|
form.MessageType = DefaultVisibility;
|
||||||
form.Language = DefaultLanguage;
|
form.Language = DefaultLanguage;
|
||||||
|
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
|
||||||
form.QuotedStatusId = Quoting?.Id;
|
form.QuotedStatusId = Quoting?.Id;
|
||||||
|
limits = await MediaService.Limits();
|
||||||
if (Editing is not null)
|
if (Editing is not null)
|
||||||
{
|
{
|
||||||
var source = await StatusService.Source(Editing.Id);
|
var source = await StatusService.Source(Editing.Id);
|
||||||
form.Content = source?.Text;
|
form.Content = source?.Text;
|
||||||
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
|
form.HasContentWarning = source?.SpoilerText is { Length: > 0 };
|
||||||
form.SpoilerText = source?.SpoilerText;
|
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;
|
IsLoading = false;
|
||||||
if (InReplyTo is null)
|
if (InReplyTo is null)
|
||||||
@@ -433,40 +505,120 @@
|
|||||||
|
|
||||||
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
|
foreach (var file in eventArgs.GetMultipleFiles(MaximumFileCount))
|
||||||
{
|
{
|
||||||
if (file.Size > MaximumFileSize)
|
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,
|
fileErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||||
file.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
file.Size.GetFileSize(Localizer), MaximumFileSize(type).GetFileSize(Localizer)]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await ReadFile(file) is { } media)
|
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);
|
form.Media.Add(media);
|
||||||
else
|
altTextConfirmed = false;
|
||||||
fileErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
Start(media);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reads a picked file into memory; null when the browser can't read it (logged).</summary>
|
// a small picture of it, which the browser makes (nothing of the whole file stays in memory); none when it can't
|
||||||
async Task<UploadMedia> ReadFile(IBrowserFile file, CancellationToken cancellationToken = default)
|
async Task<string> Thumbnail(IBrowserFile file)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var memoryStream = new MemoryStream();
|
var small = await file.RequestImageFileAsync("image/jpeg", 320, 320);
|
||||||
await using var fileStream = file.OpenReadStream(MaximumFileSize, cancellationToken);
|
await using var stream = small.OpenReadStream(2 * 1024 * 1024);
|
||||||
await fileStream.CopyToAsync(memoryStream, cancellationToken);
|
using var buffer = new MemoryStream();
|
||||||
var media = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
await stream.CopyToAsync(buffer);
|
||||||
if (file.ContentType.StartsWith("image"))
|
return $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||||
media.Base64Preview = $"data:{media.ContentType};base64,{Convert.ToBase64String(media.Blob)}";
|
|
||||||
return media;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await Logger.ProcessError(ex, $"{nameof(Composer)}.{nameof(ReadFile)}:{file.Name}", cancellationToken);
|
await Logger.ProcessWarning($"No preview of {file.Name}: {ex.Message}", $"{nameof(Composer)}.{nameof(Thumbnail)}");
|
||||||
return default;
|
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()
|
async Task Publish()
|
||||||
{
|
{
|
||||||
error = default;
|
error = default;
|
||||||
@@ -480,6 +632,18 @@
|
|||||||
error = Localizer["A poll needs two choices at least."];
|
error = Localizer["A poll needs two choices at least."];
|
||||||
return;
|
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)
|
if (Editing is not null)
|
||||||
{
|
{
|
||||||
isPublishing = true;
|
isPublishing = true;
|
||||||
|
|||||||
@@ -623,9 +623,10 @@
|
|||||||
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full" TapOpens="false">
|
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full" TapOpens="false">
|
||||||
@if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id))
|
@if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id))
|
||||||
{
|
{
|
||||||
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm"
|
@* its blurhash shows behind it: the colours, nothing of what it shows *@
|
||||||
@onclick="() => revealedMedia.Add(media.Id)">
|
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm bg-cover"
|
||||||
<i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])
|
@onclick="() => revealedMedia.Add(media.Id)" data-blurhash="@media.Blurhash">
|
||||||
|
<span class="inline-flex items-center rounded-box bg-base-100/85 px-3 py-1"><i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -776,22 +777,37 @@
|
|||||||
}
|
}
|
||||||
</div>;
|
</div>;
|
||||||
|
|
||||||
RenderFragment Media(MediaAttachment media, bool several) => media.Type switch
|
// media that didn't load (gone from their server, or refused): said so, with the way to their original
|
||||||
|
HashSet<string> unavailableMedia = [];
|
||||||
|
|
||||||
|
RenderFragment Media(MediaAttachment media, bool several) => unavailableMedia.Contains(media.Id) ? Unavailable(media) : media.Type switch
|
||||||
{
|
{
|
||||||
"image" => @<button type="button" class="block w-full cursor-zoom-in" @onclick="() => OpenPicture(media)"
|
"image" => @<button type="button" class="block w-full cursor-zoom-in" @onclick="() => OpenPicture(media)"
|
||||||
aria-label="@(media.Description ?? Localizer["Picture"])" data-picture>
|
aria-label="@(media.Description ?? Localizer["Picture"])" data-picture>
|
||||||
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
|
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
|
||||||
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" />
|
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" @onerror="() => unavailableMedia.Add(media.Id)" />
|
||||||
</button>,
|
</button>,
|
||||||
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>,
|
// under reduced motion a gifv waits for a tap (its controls)
|
||||||
|
"gifv" => @<video class="w-full rounded-lg" autoplay="@(!CascadingState.Status.PrefersReducedMotion())" controls="@CascadingState.Status.PrefersReducedMotion()" muted loop
|
||||||
|
playsinline poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
|
||||||
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
|
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
|
||||||
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url"></video>,
|
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></video>,
|
||||||
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url"></audio>,
|
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url" @onerror="() => unavailableMedia.Add(media.Id)"></audio>,
|
||||||
_ => @<a class="btn btn-sm justify-start" href="@media.Url" target="_blank" rel="noopener noreferrer">
|
_ => @<a class="btn btn-sm justify-start" href="@media.Url" target="_blank" rel="noopener noreferrer">
|
||||||
<i class="ion-md-document"></i><span class="truncate">@(media.Description ?? media.Url)</span>
|
<i class="ion-md-document"></i><span class="truncate">@(media.Description ?? media.Url)</span>
|
||||||
</a>
|
</a>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
RenderFragment Unavailable(MediaAttachment media) =>
|
||||||
|
@<div class="flex flex-col items-center justify-center gap-2 w-full aspect-video rounded-lg neo-inset neo-2xs text-sm" data-media-unavailable>
|
||||||
|
<i class="ion-md-alert text-lg opacity-70" aria-hidden="true"></i>
|
||||||
|
<span>@Localizer["Media unavailable"]</span>
|
||||||
|
@if ((media.RemoteUrl ?? media.Url) is { Length: > 0 } original)
|
||||||
|
{
|
||||||
|
<a class="link text-xs pointer-coarse:py-3.5" href="@original" target="_blank" rel="noopener noreferrer">@Localizer["Open original"]</a>
|
||||||
|
}
|
||||||
|
</div>;
|
||||||
|
|
||||||
async Task Act(StatusAction action)
|
async Task Act(StatusAction action)
|
||||||
{
|
{
|
||||||
isActing = true;
|
isActing = true;
|
||||||
|
|||||||
@@ -95,9 +95,15 @@
|
|||||||
async Task Export(PortableData data)
|
async Task Export(PortableData data)
|
||||||
{
|
{
|
||||||
exporting = data;
|
exporting = data;
|
||||||
var bytes = await PortabilityService.Export(data, CascadingState.PersonaId);
|
var result = await PortabilityService.Export(data, CascadingState.PersonaId);
|
||||||
exporting = null;
|
exporting = null;
|
||||||
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), bytes, "text/csv");
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), (byte[])result.Data, "text/csv");
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task Pick(InputFileChangeEventArgs eventArgs)
|
async Task Pick(InputFileChangeEventArgs eventArgs)
|
||||||
|
|||||||
@@ -18,19 +18,35 @@ else
|
|||||||
{
|
{
|
||||||
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4 w-full" data-profile-editor>
|
<EditForm Model="form" OnValidSubmit="Save" class="flex flex-col gap-4 w-full" data-profile-editor>
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Base64Preview ?? account.Header)" alt="@Localizer["Header"]" />
|
<img class="w-full h-28 md:h-36 object-cover rounded-xl" src="@(form.Header?.Preview ?? account.Header)" alt="@Localizer["Header"]" />
|
||||||
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Base64Preview ?? account.Avatar)"
|
<img class="absolute -bottom-6 left-4 size-20 rounded-full object-cover neo-raised neo-xs" src="@(form.Avatar?.Preview ?? account.Avatar)"
|
||||||
alt="@Localizer["Avatar"]" />
|
alt="@Localizer["Avatar"]" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-3 pt-6">
|
<div class="flex flex-wrap gap-3 pt-6">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
<span>@Localizer["Avatar"]</span>
|
<span>@Localizer["Avatar"]</span>
|
||||||
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, true)" data-profile-avatar />
|
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, true)" data-profile-avatar />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
@if (HasOwn(account.Avatar) && form.Avatar is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(true)" data-profile-avatar-remove>
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the avatar"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
<span>@Localizer["Header"]</span>
|
<span>@Localizer["Header"]</span>
|
||||||
<DFileInput Size="DSize.Sm" Accept="image/png,image/jpeg,image/gif,image/webp" OnChange="e => Pick(e, false)" data-profile-header />
|
<DFileInput Size="DSize.Sm" Accept="@PictureTypes" OnChange="e => Pick(e, false)" data-profile-header />
|
||||||
</label>
|
</label>
|
||||||
|
@if (HasOwn(account.Header) && form.Header is null)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Xs" class="self-start" Disabled="IsDefaultDisabled || isSaving" OnClick="() => RemovePicture(false)" data-profile-header-remove>
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Remove the header"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 max-w-md">
|
<label class="flex flex-col gap-1 max-w-md">
|
||||||
@@ -110,11 +126,10 @@ else
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] AccountService AccountService { get; set; }
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
[Inject] MediaService MediaService { get; set; }
|
||||||
[Inject] AuthService AuthService { get; set; }
|
[Inject] AuthService AuthService { get; set; }
|
||||||
[Inject] ILoggingService Logger { get; set; }
|
[Inject] ILoggingService Logger { get; set; }
|
||||||
|
|
||||||
// PrivaPub keeps pictures up to 20 MB and resizes them
|
|
||||||
const long MaximumPictureSize = 20 * 1024 * 1024;
|
|
||||||
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
|
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
|
||||||
|
|
||||||
Account account;
|
Account account;
|
||||||
@@ -122,6 +137,13 @@ else
|
|||||||
string loadedPersonaId;
|
string loadedPersonaId;
|
||||||
bool isSaving;
|
bool isSaving;
|
||||||
List<string> pictureErrors = [];
|
List<string> pictureErrors = [];
|
||||||
|
// the picture types the server takes, read with the account
|
||||||
|
List<string> pictureTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||||
|
|
||||||
|
string PictureTypes => string.Join(",", pictureTypes);
|
||||||
|
|
||||||
|
// a picture of its own, not the server's default
|
||||||
|
static bool HasOwn(string url) => url is { Length: > 0 } && !url.Contains("/missing-", StringComparison.Ordinal);
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
@@ -145,6 +167,7 @@ else
|
|||||||
{
|
{
|
||||||
// the account as the persona sees itself, with its Source
|
// the account as the persona sees itself, with its Source
|
||||||
account = await AuthService.CurrentAccount();
|
account = await AuthService.CurrentAccount();
|
||||||
|
pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/"))];
|
||||||
Reset();
|
Reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,30 +188,55 @@ else
|
|||||||
{
|
{
|
||||||
pictureErrors = [];
|
pictureErrors = [];
|
||||||
var file = eventArgs.File;
|
var file = eventArgs.File;
|
||||||
if (file.Size > MaximumPictureSize)
|
if (!pictureTypes.Contains(file.ContentType))
|
||||||
|
{
|
||||||
|
pictureErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.Size > ProfileForm.MaximumPictureSize)
|
||||||
{
|
{
|
||||||
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
pictureErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name,
|
||||||
file.Size.GetFileSize(Localizer), MaximumPictureSize.GetFileSize(Localizer)]);
|
file.Size.GetFileSize(Localizer), ProfileForm.MaximumPictureSize.GetFileSize(Localizer)]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the file is sent as it is when saving; only a small picture of it is kept to show it now
|
||||||
|
var picture = new UploadMedia { File = file, FileName = file.Name, ContentType = file.ContentType, Size = file.Size };
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var memoryStream = new MemoryStream();
|
var small = await file.RequestImageFileAsync("image/jpeg", isAvatar ? 240 : 900, isAvatar ? 240 : 300);
|
||||||
await using var fileStream = file.OpenReadStream(MaximumPictureSize);
|
await using var stream = small.OpenReadStream(4 * 1024 * 1024);
|
||||||
await fileStream.CopyToAsync(memoryStream);
|
using var buffer = new MemoryStream();
|
||||||
var picture = new UploadMedia { FileName = file.Name, ContentType = file.ContentType, Size = file.Size, Blob = memoryStream.ToArray() };
|
await stream.CopyToAsync(buffer);
|
||||||
picture.Base64Preview = $"data:{picture.ContentType};base64,{Convert.ToBase64String(picture.Blob)}";
|
picture.Preview = $"data:image/jpeg;base64,{Convert.ToBase64String(buffer.ToArray())}";
|
||||||
if (isAvatar)
|
|
||||||
form.Avatar = picture;
|
|
||||||
else
|
|
||||||
form.Header = picture;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await Logger.ProcessError(ex, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
await Logger.ProcessError(ex, $"{nameof(ProfileEditor)}.{nameof(Pick)}:{file.Name}");
|
||||||
pictureErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
pictureErrors.Add(Localizer["The file \"{0}\" could not be read.", file.Name]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (isAvatar)
|
||||||
|
form.Avatar = picture;
|
||||||
|
else
|
||||||
|
form.Header = picture;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RemovePicture(bool avatar)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await AccountService.RemovePicture(avatar);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the rest of the form stays as being written: it shows the server's pictures, which are the account's
|
||||||
|
account = (Account)result.Data;
|
||||||
|
CascadingState.PersonaChanged(account);
|
||||||
|
Toast.AddResponseSuccess(avatar ? Localizer["Avatar removed."] : Localizer["Header removed."]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task Save()
|
async Task Save()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(List<string>))]
|
[JsonSerializable(typeof(List<string>))]
|
||||||
[JsonSerializable(typeof(SearchResults))]
|
[JsonSerializable(typeof(SearchResults))]
|
||||||
[JsonSerializable(typeof(MediaAttachment))]
|
[JsonSerializable(typeof(MediaAttachment))]
|
||||||
|
[JsonSerializable(typeof(InstanceV2))]
|
||||||
[JsonSerializable(typeof(Notification))]
|
[JsonSerializable(typeof(Notification))]
|
||||||
[JsonSerializable(typeof(List<Notification>))]
|
[JsonSerializable(typeof(List<Notification>))]
|
||||||
[JsonSerializable(typeof(Conversation))]
|
[JsonSerializable(typeof(Conversation))]
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ public static class APIs
|
|||||||
public static string DELETE_M_Conversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}";
|
public static string DELETE_M_Conversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}";
|
||||||
public const string GET_Notifications = "api/v1/notifications";
|
public const string GET_Notifications = "api/v1/notifications";
|
||||||
public const string POST_Media = "api/v2/media";
|
public const string POST_Media = "api/v2/media";
|
||||||
|
public static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
|
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||||
|
public const string GET_InstanceV2 = "api/v2/instance";
|
||||||
|
public const string DELETE_ProfileAvatar = "api/v1/profile/avatar";
|
||||||
|
public const string DELETE_ProfileHeader = "api/v1/profile/header";
|
||||||
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
public const string GET_AccountLookup = "api/v1/accounts/lookup";
|
||||||
public const string GET_Relationships = "api/v1/accounts/relationships";
|
public const string GET_Relationships = "api/v1/accounts/relationships";
|
||||||
public const string GET_FollowRequests = "api/v1/follow_requests";
|
public const string GET_FollowRequests = "api/v1/follow_requests";
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public class ComposeForm
|
|||||||
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
public MessageType MessageType { get; set; } = MessageType.Public;
|
public MessageType MessageType { get; set; } = MessageType.Public;
|
||||||
public bool HasContentWarning { get; set; }
|
public bool HasContentWarning { get; set; }
|
||||||
|
// its media marked sensitive without a warning: the persona's default ("always mark media as sensitive"), or the post's own when edited
|
||||||
|
public bool Sensitive { get; set; }
|
||||||
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[StringLength(500, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
[StringLength(MaxContentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>/api/v2/instance, the part the client reads: what an upload may be.</summary>
|
||||||
|
public class InstanceV2
|
||||||
|
{
|
||||||
|
public InstanceConfiguration Configuration { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InstanceConfiguration
|
||||||
|
{
|
||||||
|
public MediaConfiguration MediaAttachments { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The types an upload may have and how big it may be, as the server enforces them.</summary>
|
||||||
|
public class MediaConfiguration
|
||||||
|
{
|
||||||
|
public List<string> SupportedMimeTypes { get; set; } = [];
|
||||||
|
public long ImageSizeLimit { get; set; }
|
||||||
|
public long VideoSizeLimit { get; set; }
|
||||||
|
public long ImageMatrixLimit { get; set; }
|
||||||
|
public long VideoMatrixLimit { get; set; }
|
||||||
|
public int VideoFrameRateLimit { get; set; }
|
||||||
|
}
|
||||||
@@ -189,13 +189,23 @@ public class PollForm
|
|||||||
public bool Multiple { get; set; }
|
public bool Multiple { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>What an edit sends: the words and their warning; the media stay as they are.</summary>
|
/// <summary>What an edit sends, the whole post as Mastodon wants it: the words, their warning and sensitive flag, the
|
||||||
|
/// post's language, the media it keeps (media_ids) and the descriptions changed (media_attributes).</summary>
|
||||||
public class StatusEditForm
|
public class StatusEditForm
|
||||||
{
|
{
|
||||||
public string Status { get; set; }
|
public string Status { get; set; }
|
||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
public bool Sensitive { get; set; }
|
public bool Sensitive { get; set; }
|
||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
|
public List<string> MediaIds { get; set; } = [];
|
||||||
|
public List<MediaAttributeForm> MediaAttributes { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A posted attachment's new description, sent with the edit.</summary>
|
||||||
|
public class MediaAttributeForm
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>/api/v1/statuses/:id/source: the author's own text of a post, to edit it.</summary>
|
/// <summary>/api/v1/statuses/:id/source: the author's own text of a post, to edit it.</summary>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ namespace decePubClient.Models
|
|||||||
public bool ThemeIsDarkGray { get; set; } = false;
|
public bool ThemeIsDarkGray { get; set; } = false;
|
||||||
|
|
||||||
public bool ShowDonatorBadge { get; set; } = false;
|
public bool ShowDonatorBadge { get; set; } = false;
|
||||||
|
// before publishing pictures without a description, the composer asks once
|
||||||
|
public bool RemindAltText { get; set; } = true;
|
||||||
|
|
||||||
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
|
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
|
||||||
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
|
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
|
||||||
|
|||||||
@@ -91,10 +91,14 @@ namespace decePubClient.Models
|
|||||||
|
|
||||||
static string Flag(bool value) => value ? "true" : "false";
|
static string Flag(bool value) => value ? "true" : "false";
|
||||||
|
|
||||||
static ByteArrayContent Picture(UploadMedia media)
|
// PrivaPub keeps pictures up to this size and resizes them
|
||||||
|
public const long MaximumPictureSize = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
// streamed from the browser's file, read again for each attempt
|
||||||
|
static StreamContent Picture(UploadMedia media)
|
||||||
{
|
{
|
||||||
var picture = new ByteArrayContent(media.Blob);
|
var picture = new StreamContent(media.File.OpenReadStream(MaximumPictureSize));
|
||||||
picture.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(media.ContentType);
|
picture.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(media.ContentType);
|
||||||
return picture;
|
return picture;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-4
@@ -1,17 +1,48 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
|
|
||||||
namespace decePubClient.Models;
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
|
public enum UploadState
|
||||||
|
{
|
||||||
|
Uploading,
|
||||||
|
Processing,
|
||||||
|
Ready,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A file attached in the composer. It is uploaded as soon as it is picked, streamed from the browser's file (nothing of
|
||||||
|
/// it is kept but a small preview), and its id is kept, so a post that fails never sends it again.
|
||||||
|
/// </summary>
|
||||||
public class UploadMedia
|
public class UploadMedia
|
||||||
{
|
{
|
||||||
public string ContentType { get; set; }
|
public string ContentType { get; set; }
|
||||||
public string FileName { get; set; }
|
public string FileName { get; set; }
|
||||||
public string AltText { get; set; }
|
public string AltText { get; set; }
|
||||||
public byte[] Blob { get; set; } = [];
|
public long Size { get; set; }
|
||||||
|
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public string Base64Preview { get; set; }
|
public IBrowserFile File { get; set; }
|
||||||
|
/// <summary>A small picture of it (a thumbnail the browser made), or for media already posted its server's preview.</summary>
|
||||||
[JsonIgnore, Bindable(false)]
|
[JsonIgnore, Bindable(false)]
|
||||||
public long Size { get; set; }
|
public string Preview { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public UploadState State { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string Id { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string Error { get; set; }
|
||||||
|
/// <summary>The description the server holds: a different one is sent before the post.</summary>
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public string SentAltText { get; set; }
|
||||||
|
/// <summary>Already on the post being edited: its description changes through the edit.</summary>
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public bool Posted { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public CancellationTokenSource Cancel { get; set; }
|
||||||
|
[JsonIgnore, Bindable(false)]
|
||||||
|
public Task Done { get; set; } = Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
||||||
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
|
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
|
||||||
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
|
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
|
||||||
|
<DToggle LabelText="@Localizer["Remind me to describe pictures before publishing"]" Value="CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
||||||
|
ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
||||||
|
data-remind-alt-text />
|
||||||
<label class="flex flex-col gap-2 max-w-md">
|
<label class="flex flex-col gap-2 max-w-md">
|
||||||
<span class="text-sm">@Localizer["Language"]</span>
|
<span class="text-sm">@Localizer["Language"]</span>
|
||||||
@* Language names stay in their own language. Changing it reloads the app with the new culture. *@
|
@* Language names stay in their own language. Changing it reloads the app with the new culture. *@
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ builder.Services.AddHttpClient("default", client =>
|
|||||||
{
|
{
|
||||||
client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/");
|
client.BaseAddress = new Uri(string.IsNullOrEmpty(apiBaseAddress) ? builder.HostEnvironment.BaseAddress : apiBaseAddress.TrimEnd('/') + "/");
|
||||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
// each request has its own timeout (HttpService.Measure): an upload may take minutes, anything else 100 s
|
||||||
|
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddSingleton(serviceProvider =>
|
builder.Services.AddSingleton(serviceProvider =>
|
||||||
|
|||||||
@@ -1898,4 +1898,46 @@
|
|||||||
<data name="{0}× anisotropic" xml:space="preserve">
|
<data name="{0}× anisotropic" xml:space="preserve">
|
||||||
<value>anisotropia {0}×</value>
|
<value>anisotropia {0}×</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Avatar removed." xml:space="preserve">
|
||||||
|
<value>Avatar rimosso.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Header removed." xml:space="preserve">
|
||||||
|
<value>Intestazione rimossa.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Describe them" xml:space="preserve">
|
||||||
|
<value>Descrivile</value>
|
||||||
|
</data>
|
||||||
|
<data name="Media unavailable" xml:space="preserve">
|
||||||
|
<value>Contenuto non disponibile</value>
|
||||||
|
</data>
|
||||||
|
<data name="Processing" xml:space="preserve">
|
||||||
|
<value>In elaborazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish anyway" xml:space="preserve">
|
||||||
|
<value>Pubblica comunque</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ready" xml:space="preserve">
|
||||||
|
<value>Pronto</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remind me to describe pictures before publishing" xml:space="preserve">
|
||||||
|
<value>Ricordami di descrivere le immagini prima di pubblicare</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the avatar" xml:space="preserve">
|
||||||
|
<value>Rimuovi l'avatar</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the header" xml:space="preserve">
|
||||||
|
<value>Rimuovi l'intestazione</value>
|
||||||
|
</data>
|
||||||
|
<data name="Uploading" xml:space="preserve">
|
||||||
|
<value>Caricamento</value>
|
||||||
|
</data>
|
||||||
|
<data name="Wait for the files to finish uploading, or remove the ones that failed." xml:space="preserve">
|
||||||
|
<value>Attendi che i file finiscano di caricarsi, o rimuovi quelli non riusciti.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} of the pictures have no description, which people who can't see them rely on." xml:space="preserve">
|
||||||
|
<value>{0} delle immagini non hanno una descrizione, su cui conta chi non può vederle.</value>
|
||||||
|
</data>
|
||||||
|
<data name=""{0}" is not a kind of file this server takes." xml:space="preserve">
|
||||||
|
<value>"{0}" non è un tipo di file che questo server accetta.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -1898,4 +1898,46 @@
|
|||||||
<data name="{0}× anisotropic" xml:space="preserve">
|
<data name="{0}× anisotropic" xml:space="preserve">
|
||||||
<value>{0}× anisotropic</value>
|
<value>{0}× anisotropic</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Avatar removed." xml:space="preserve">
|
||||||
|
<value>Avatar removed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Header removed." xml:space="preserve">
|
||||||
|
<value>Header removed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Describe them" xml:space="preserve">
|
||||||
|
<value>Describe them</value>
|
||||||
|
</data>
|
||||||
|
<data name="Media unavailable" xml:space="preserve">
|
||||||
|
<value>Media unavailable</value>
|
||||||
|
</data>
|
||||||
|
<data name="Processing" xml:space="preserve">
|
||||||
|
<value>Processing</value>
|
||||||
|
</data>
|
||||||
|
<data name="Publish anyway" xml:space="preserve">
|
||||||
|
<value>Publish anyway</value>
|
||||||
|
</data>
|
||||||
|
<data name="Ready" xml:space="preserve">
|
||||||
|
<value>Ready</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remind me to describe pictures before publishing" xml:space="preserve">
|
||||||
|
<value>Remind me to describe pictures before publishing</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the avatar" xml:space="preserve">
|
||||||
|
<value>Remove the avatar</value>
|
||||||
|
</data>
|
||||||
|
<data name="Remove the header" xml:space="preserve">
|
||||||
|
<value>Remove the header</value>
|
||||||
|
</data>
|
||||||
|
<data name="Uploading" xml:space="preserve">
|
||||||
|
<value>Uploading</value>
|
||||||
|
</data>
|
||||||
|
<data name="Wait for the files to finish uploading, or remove the ones that failed." xml:space="preserve">
|
||||||
|
<value>Wait for the files to finish uploading, or remove the ones that failed.</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} of the pictures have no description, which people who can't see them rely on." xml:space="preserve">
|
||||||
|
<value>{0} of the pictures have no description, which people who can't see them rely on.</value>
|
||||||
|
</data>
|
||||||
|
<data name=""{0}" is not a kind of file this server takes." xml:space="preserve">
|
||||||
|
<value>"{0}" is not a kind of file this server takes.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -221,6 +221,20 @@ namespace decePubClient.Services
|
|||||||
: new WebResult { Data = account };
|
: new WebResult { Data = account };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the persona's avatar or header (the default shows again); Data is the account as it is now.</summary>
|
||||||
|
public async Task<WebResult> RemovePicture(bool avatar, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(avatar ? APIs.Mastodon.DELETE_ProfileAvatar : APIs.Mastodon.DELETE_ProfileHeader,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
|
||||||
|
return account?.Id is null
|
||||||
|
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
||||||
|
: new WebResult { Data = account };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A page of the accounts the persona follows (only the persona itself may read it); Data is an AccountPage.</summary>
|
/// <summary>A page of the accounts the persona follows (only the persona itself may read it); Data is an AccountPage.</summary>
|
||||||
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
||||||
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
|
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ public class AppStatusService(IJSRuntime jsRuntime)
|
|||||||
// whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap
|
// whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap
|
||||||
public bool CanHover() => JsSyncRuntime.Invoke<bool>("canHover");
|
public bool CanHover() => JsSyncRuntime.Invoke<bool>("canHover");
|
||||||
|
|
||||||
|
// read once: a card asks it for each gifv it shows
|
||||||
|
bool? reducedMotion;
|
||||||
|
|
||||||
|
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
|
||||||
|
|
||||||
// a sideways-scrolling strip of tabs shows its selected tab
|
// a sideways-scrolling strip of tabs shows its selected tab
|
||||||
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
|
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
|
||||||
|
|
||||||
|
|||||||
@@ -310,10 +310,24 @@ namespace decePubClient.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Sends the request and records it in RequestStats.</summary>
|
/// <summary>Sends the request and records it in RequestStats.</summary>
|
||||||
|
static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(100);
|
||||||
|
static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
|
async Task<HttpResponseMessage> Measure(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// as HttpClient's own timeout would, but per request: a multipart body (an upload) may take minutes
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(request.Content is MultipartContent ? UploadTimeout : RequestTimeout);
|
||||||
var started = Stopwatch.GetTimestamp();
|
var started = Stopwatch.GetTimestamp();
|
||||||
var response = await httpClient.SendAsync(request, cancellationToken);
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await httpClient.SendAsync(request, timeout.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TaskCanceledException("The request timed out.", new TimeoutException(ex.Message));
|
||||||
|
}
|
||||||
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
|
var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds;
|
||||||
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
|
requestStats.Record(new(DateTime.UtcNow, request.Method.Method, RequestStats.Template(request.RequestUri?.OriginalString), (int)response.StatusCode,
|
||||||
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
|
elapsed, response.Content.Headers.ContentLength, HeaderInt(response, "X-RateLimit-Remaining"), HeaderTime(response, "X-RateLimit-Reset")));
|
||||||
|
|||||||
@@ -11,16 +11,41 @@ using PrivaPub.ClientModels;
|
|||||||
|
|
||||||
namespace decePubClient.Services
|
namespace decePubClient.Services
|
||||||
{
|
{
|
||||||
/// <summary>Uploads a picked file to PrivaPub (/api/v2/media, multipart) before the post that uses it.</summary>
|
/// <summary>
|
||||||
|
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
|
||||||
|
/// (audio and video: 202, then 206 until ready); describes what isn't posted yet; and knows what PrivaPub takes.
|
||||||
|
/// </summary>
|
||||||
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
|
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
/// <summary>The uploaded attachment as Data; its id goes in the post's media_ids.</summary>
|
static readonly MediaConfiguration Fallback = new()
|
||||||
public async Task<WebResult> Upload(UploadMedia media, CancellationToken cancellationToken = default)
|
{
|
||||||
|
SupportedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "video/mp4", "video/quicktime", "video/webm",
|
||||||
|
"audio/mpeg", "audio/mp3", "audio/ogg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/x-m4a", "audio/flac", "audio/webm"],
|
||||||
|
ImageSizeLimit = 16 * 1024 * 1024,
|
||||||
|
VideoSizeLimit = 99 * 1024 * 1024
|
||||||
|
};
|
||||||
|
|
||||||
|
MediaConfiguration limits;
|
||||||
|
|
||||||
|
/// <summary>What PrivaPub takes, read once a session (/api/v2/instance); its defaults while it can't be read.</summary>
|
||||||
|
public async Task<MediaConfiguration> Limits(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (limits is not null)
|
||||||
|
return limits;
|
||||||
|
using var response = await httpService.GetAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode
|
||||||
|
&& await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) is { Configuration.MediaAttachments.SupportedMimeTypes.Count: > 0 } instance)
|
||||||
|
return limits = instance.Configuration.MediaAttachments;
|
||||||
|
return Fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The attachment as Data; its Url is null while PrivaPub still processes it (follow it with Get).</summary>
|
||||||
|
public async Task<WebResult> Upload(UploadMedia media, long maximumSize, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
|
using var response = await httpService.Post(APIs.Mastodon.POST_Media, (Func<HttpContent>)(() =>
|
||||||
{
|
{
|
||||||
var file = new ByteArrayContent(media.Blob);
|
var file = new StreamContent(media.File.OpenReadStream(maximumSize, cancellationToken));
|
||||||
file.Headers.ContentType = MediaTypeHeaderValue.Parse(media.ContentType);
|
file.Headers.ContentType = new MediaTypeHeaderValue(media.ContentType);
|
||||||
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
|
var content = new MultipartFormDataContent { { file, "file", media.FileName } };
|
||||||
if (media.AltText is { Length: > 0 } description)
|
if (media.AltText is { Length: > 0 } description)
|
||||||
content.Add(new StringContent(description), "description");
|
content.Add(new StringContent(description), "description");
|
||||||
@@ -29,6 +54,35 @@ namespace decePubClient.Services
|
|||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return await Attachment(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An upload PrivaPub is processing: Data is the attachment once it is ready, null while it isn't; invalid
|
||||||
|
/// when it can't be.</summary>
|
||||||
|
public async Task<WebResult> Get(string mediaId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_Media(mediaId), cancellationToken: cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.PartialContent)
|
||||||
|
return new WebResult();
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return await Attachment(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Describes an upload not posted yet (a posted one is described again by editing its post).</summary>
|
||||||
|
public async Task<WebResult> Describe(string mediaId, string description, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Media(mediaId),
|
||||||
|
new KeyValuePair<string, string>[] { new("description", description ?? string.Empty) }, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
|
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
|
||||||
return attachment?.Id is null
|
return attachment?.Id is null
|
||||||
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using collAnon.Client.Services;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
@@ -35,7 +36,7 @@ namespace decePubClient.Services
|
|||||||
/// reports the rows it could not do.
|
/// reports the rows it could not do.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PortabilityService(AccountService accounts, ListService lists, StatusService statuses, TimelineService timelines,
|
public class PortabilityService(AccountService accounts, ListService lists, StatusService statuses, TimelineService timelines,
|
||||||
InstanceService instances)
|
InstanceService instances, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
/// <summary>The most rows read from an export or an import file.</summary>
|
/// <summary>The most rows read from an export or an import file.</summary>
|
||||||
public const int MaxRows = 5000;
|
public const int MaxRows = 5000;
|
||||||
@@ -53,23 +54,33 @@ namespace decePubClient.Services
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>The file Mastodon would export, as UTF-8 bytes.</summary>
|
/// <summary>The file Mastodon would export, as UTF-8 bytes.</summary>
|
||||||
public async Task<byte[]> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
|
// a page that could not be read stops the export, which says so, instead of a file silently missing the rest
|
||||||
|
sealed class Stop
|
||||||
{
|
{
|
||||||
var rows = data switch
|
public WebResult Failed { get; set; }
|
||||||
{
|
|
||||||
PortableData.Follows => await Follows(personaId, cancellationToken),
|
|
||||||
PortableData.Lists => await Lists(cancellationToken),
|
|
||||||
PortableData.Blocks => [.. (await Pages(accounts.Blocks, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
|
|
||||||
PortableData.Mutes => await Mutes(cancellationToken),
|
|
||||||
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
|
|
||||||
_ => await Bookmarks(personaId, cancellationToken)
|
|
||||||
};
|
|
||||||
return Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<List<string>> Follows(string personaId, CancellationToken cancellationToken)
|
/// <summary>The file, as Data (bytes); invalid when a page of it could not be read.</summary>
|
||||||
|
public async Task<WebResult> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), cancellationToken);
|
var stop = new Stop();
|
||||||
|
var rows = data switch
|
||||||
|
{
|
||||||
|
PortableData.Follows => await Follows(personaId, stop, cancellationToken),
|
||||||
|
PortableData.Lists => await Lists(cancellationToken),
|
||||||
|
PortableData.Blocks => [.. (await Pages(accounts.Blocks, stop, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
|
||||||
|
PortableData.Mutes => await Mutes(stop, cancellationToken),
|
||||||
|
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
|
||||||
|
_ => await Bookmarks(personaId, stop, cancellationToken)
|
||||||
|
};
|
||||||
|
if (stop.Failed is { } failed)
|
||||||
|
return failed;
|
||||||
|
return new WebResult { Data = Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n"))) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<string>> Follows(string personaId, Stop stop, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), stop, cancellationToken);
|
||||||
var relationships = (await accounts.Relationships([.. followed.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
var relationships = (await accounts.Relationships([.. followed.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
@@ -80,9 +91,9 @@ namespace decePubClient.Services
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<List<string>> Mutes(CancellationToken cancellationToken)
|
async Task<List<string>> Mutes(Stop stop, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var muted = await Pages(accounts.Mutes, cancellationToken);
|
var muted = await Pages(accounts.Mutes, stop, cancellationToken);
|
||||||
var relationships = (await accounts.Relationships([.. muted.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
var relationships = (await accounts.Relationships([.. muted.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
@@ -99,7 +110,7 @@ namespace decePubClient.Services
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<List<string>> Bookmarks(string personaId, CancellationToken cancellationToken)
|
async Task<List<string>> Bookmarks(string personaId, Stop stop, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var rows = new List<string>();
|
var rows = new List<string>();
|
||||||
var maxId = default(string);
|
var maxId = default(string);
|
||||||
@@ -107,7 +118,10 @@ namespace decePubClient.Services
|
|||||||
{
|
{
|
||||||
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
|
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
|
||||||
if (result.IsInvalid || result.Data is not FeedPage page)
|
if (result.IsInvalid || result.Data is not FeedPage page)
|
||||||
|
{
|
||||||
|
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
|
||||||
return rows;
|
return rows;
|
||||||
|
}
|
||||||
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
|
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
|
||||||
maxId = page.Posts.Count == 0 ? null : page.MaxId;
|
maxId = page.Posts.Count == 0 ? null : page.MaxId;
|
||||||
}
|
}
|
||||||
@@ -116,7 +130,7 @@ namespace decePubClient.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
// every page of an account list, up to MaxRows
|
// every page of an account list, up to MaxRows
|
||||||
static async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, CancellationToken cancellationToken)
|
async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, Stop stop, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var all = new List<Account>();
|
var all = new List<Account>();
|
||||||
var maxId = default(string);
|
var maxId = default(string);
|
||||||
@@ -124,7 +138,10 @@ namespace decePubClient.Services
|
|||||||
{
|
{
|
||||||
var result = await page(maxId, cancellationToken);
|
var result = await page(maxId, cancellationToken);
|
||||||
if (result.IsInvalid || result.Data is not AccountPage accountPage)
|
if (result.IsInvalid || result.Data is not AccountPage accountPage)
|
||||||
|
{
|
||||||
|
stop.Failed = result.IsInvalid ? result : new WebResult().Invalidate(localizer["The server sent an invalid response."]);
|
||||||
return all;
|
return all;
|
||||||
|
}
|
||||||
all.AddRange(accountPage.Accounts);
|
all.AddRange(accountPage.Accounts);
|
||||||
maxId = accountPage.MaxId;
|
maxId = accountPage.MaxId;
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-13
@@ -33,27 +33,25 @@ namespace decePubClient.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
{
|
{
|
||||||
/// <summary>Publishes the form as the persona; its media are uploaded first.</summary>
|
/// <summary>Publishes the form as the persona. Its media are uploaded already (the composer uploads a file as soon as
|
||||||
|
/// it is picked); a description changed since is sent first.</summary>
|
||||||
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (form.IsLocated || form.GroupId is not null)
|
if (form.IsLocated || form.GroupId is not null)
|
||||||
return await PublishThroughClientApi(form, personaId, cancellationToken);
|
return await PublishThroughClientApi(form, personaId, cancellationToken);
|
||||||
|
|
||||||
var mediaIds = new List<string>();
|
if (form.Media.Any(m => m.State != UploadState.Ready))
|
||||||
foreach (var media in form.Media)
|
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
|
||||||
{
|
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
|
||||||
var uploaded = await mediaService.Upload(media, cancellationToken);
|
return undescribed;
|
||||||
if (uploaded.IsInvalid)
|
var mediaIds = form.Media.Select(m => m.Id).ToList();
|
||||||
return uploaded;
|
|
||||||
mediaIds.Add(((MediaAttachment)uploaded.Data).Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
|
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
|
||||||
{
|
{
|
||||||
Status = form.Content ?? string.Empty,
|
Status = form.Content ?? string.Empty,
|
||||||
MediaIds = mediaIds,
|
MediaIds = mediaIds,
|
||||||
InReplyToId = form.InReplyToId,
|
InReplyToId = form.InReplyToId,
|
||||||
Sensitive = form.HasContentWarning,
|
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
||||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||||
Visibility = form.Visibility,
|
Visibility = form.Visibility,
|
||||||
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||||
@@ -70,6 +68,20 @@ namespace decePubClient.Services
|
|||||||
return new WebResult { Data = FeedPost.Of(status) };
|
return new WebResult { Data = FeedPost.Of(status) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a new upload whose description changed after it was picked tells PrivaPub before the post (a posted one is
|
||||||
|
// described again through the edit's media_attributes)
|
||||||
|
async Task<WebResult> Describe(ComposeForm form, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var media in form.Media.Where(m => !m.Posted && m.AltText != m.SentAltText))
|
||||||
|
{
|
||||||
|
var described = await mediaService.Describe(media.Id, media.AltText, cancellationToken);
|
||||||
|
if (described.IsInvalid)
|
||||||
|
return described;
|
||||||
|
media.SentAltText = media.AltText;
|
||||||
|
}
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The persona's own text of a post, to edit it; null when it cannot be read.</summary>
|
/// <summary>The persona's own text of a post, to edit it; null when it cannot be read.</summary>
|
||||||
public async Task<StatusSource> Source(string statusId, CancellationToken cancellationToken = default)
|
public async Task<StatusSource> Source(string statusId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -104,15 +116,23 @@ namespace decePubClient.Services
|
|||||||
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Edits the persona's own post: new words and warning, the media kept; Data is the edited Status.</summary>
|
/// <summary>Edits the persona's own post, sent whole as Mastodon wants it: its words and warning, its sensitive flag
|
||||||
|
/// and language as they were unless changed, the media it keeps (new ones uploaded already) and the descriptions
|
||||||
|
/// changed; Data is the edited Status.</summary>
|
||||||
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
|
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (form.Media.Any(m => m.State != UploadState.Ready))
|
||||||
|
return new WebResult().Invalidate(localizer["Wait for the files to finish uploading, or remove the ones that failed."]);
|
||||||
|
if (await Describe(form, cancellationToken) is { IsInvalid: true } undescribed)
|
||||||
|
return undescribed;
|
||||||
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Status(statusId), new StatusEditForm
|
using var response = await httpService.Put(APIs.Mastodon.PUT_M_Status(statusId), new StatusEditForm
|
||||||
{
|
{
|
||||||
Status = form.Content ?? string.Empty,
|
Status = form.Content ?? string.Empty,
|
||||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||||
Sensitive = form.HasContentWarning,
|
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
||||||
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
|
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||||
|
MediaIds = [.. form.Media.Select(m => m.Id)],
|
||||||
|
MediaAttributes = [.. form.Media.Where(m => m.Posted && m.AltText != m.SentAltText).Select(m => new MediaAttributeForm { Id = m.Id, Description = m.AltText ?? string.Empty })]
|
||||||
}, cancellationToken: cancellationToken);
|
}, cancellationToken: cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Actions
|
||||||
|
{
|
||||||
|
// Media from the composer and the profile, checked on PrivaPub: a picture uploaded as soon as it is picked and posted
|
||||||
|
// with its description, the reminder before a picture without one, a failed upload retried without the rest being sent
|
||||||
|
// again, and an avatar set and removed
|
||||||
|
public sealed class MediaTests(Browser browser)
|
||||||
|
{
|
||||||
|
// a 1×1 PNG
|
||||||
|
static readonly byte[] Png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==");
|
||||||
|
|
||||||
|
static async Task OpenComposer(IPage page)
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/");
|
||||||
|
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/");
|
||||||
|
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Task Attach(IPage page, string name) =>
|
||||||
|
page.Locator("input[data-attach]").SetInputFilesAsync(new FilePayload { Name = name, MimeType = "image/png", Buffer = Png });
|
||||||
|
|
||||||
|
static ILocator Submit(IPage page) => page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
|
||||||
|
|
||||||
|
static async Task<JsonNode> Published(Pasture.Persona persona, string text)
|
||||||
|
{
|
||||||
|
JsonNode latest = null;
|
||||||
|
Assert.True(await Pasture.Eventually(async () =>
|
||||||
|
(latest = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault())?["content"]
|
||||||
|
?.GetValue<string>().Contains(text) == true, 15), "the post was never published");
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_picture_is_uploaded_when_picked_and_posted_with_its_description()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().First();
|
||||||
|
var text = $"a picture {Guid.NewGuid():N}"[..20];
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
string made = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.media", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
await OpenComposer(page);
|
||||||
|
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||||
|
await Attach(page, "dot.png");
|
||||||
|
// uploaded on its own, before anything is published
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||||
|
await page.Locator("[data-alt-text]").First.FillAsync("a single dot");
|
||||||
|
await Submit(page).ClickAsync();
|
||||||
|
|
||||||
|
var post = await Published(persona, text);
|
||||||
|
made = post["id"]!.GetValue<string>();
|
||||||
|
Assert.Equal("a single dot", post["media_attachments"]![0]!["description"]!.GetValue<string>());
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (made is not null)
|
||||||
|
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_picture_without_a_description_is_asked_about_once()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().First();
|
||||||
|
var text = $"undescribed {Guid.NewGuid():N}"[..22];
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
string made = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.media-reminder", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
await OpenComposer(page);
|
||||||
|
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||||
|
await Attach(page, "dot.png");
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||||
|
await Submit(page).ClickAsync();
|
||||||
|
|
||||||
|
await Assertions.Expect(page.Locator("[data-alt-reminder]")).ToBeVisibleAsync();
|
||||||
|
await page.Locator("[data-alt-publish-anyway]").ClickAsync();
|
||||||
|
|
||||||
|
var post = await Published(persona, text);
|
||||||
|
made = post["id"]!.GetValue<string>();
|
||||||
|
Assert.Null(post["media_attachments"]![0]!["description"]?.GetValue<string>());
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (made is not null)
|
||||||
|
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_failed_upload_is_retried_alone()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().First();
|
||||||
|
var text = $"retried {Guid.NewGuid():N}"[..18];
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
string made = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.media-retry", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
// the second upload is refused once, as a server would refuse it
|
||||||
|
var uploads = 0;
|
||||||
|
await page.RouteAsync("**/api/v2/media", async route =>
|
||||||
|
{
|
||||||
|
if (Interlocked.Increment(ref uploads) == 2)
|
||||||
|
await route.FulfillAsync(new() { Status = 422, ContentType = "application/json", Body = "{\"error\":\"Validation failed: try again\"}" });
|
||||||
|
else
|
||||||
|
await route.ContinueAsync();
|
||||||
|
});
|
||||||
|
await OpenComposer(page);
|
||||||
|
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||||
|
await Attach(page, "first.png");
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||||
|
await Attach(page, "second.png");
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToContainTextAsync("try again");
|
||||||
|
|
||||||
|
await page.Locator("[data-upload-retry]").ClickAsync();
|
||||||
|
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(2, new() { Timeout = 20000 });
|
||||||
|
foreach (var alt in await page.Locator("[data-alt-text]").AllAsync())
|
||||||
|
await alt.FillAsync("a dot");
|
||||||
|
await Submit(page).ClickAsync();
|
||||||
|
|
||||||
|
var post = await Published(persona, text);
|
||||||
|
made = post["id"]!.GetValue<string>();
|
||||||
|
Assert.Equal(2, post["media_attachments"]!.AsArray().Count);
|
||||||
|
// the first file was sent once: one upload for it, two for the second (refused, then retried)
|
||||||
|
Assert.Equal(3, uploads);
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (made is not null)
|
||||||
|
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_avatar_is_set_and_removed_from_settings()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().Last();
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.action.avatar", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||||
|
var header = page.Locator(".collapse-title", new() { HasText = "Profile" }).First;
|
||||||
|
await header.ClickAsync();
|
||||||
|
var editor = header.Locator("xpath=..").Locator("[data-profile-editor]");
|
||||||
|
await editor.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
|
||||||
|
await editor.Locator("input[data-profile-avatar]").SetInputFilesAsync(new FilePayload { Name = "me.png", MimeType = "image/png", Buffer = Png });
|
||||||
|
await editor.Locator("[data-profile-save]").ClickAsync();
|
||||||
|
await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||||
|
var set = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>();
|
||||||
|
Assert.DoesNotContain("missing", set);
|
||||||
|
|
||||||
|
await editor.Locator("[data-profile-avatar-remove]").ClickAsync();
|
||||||
|
await Assertions.Expect(page.GetByText("Avatar removed.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||||
|
Assert.Contains("missing", (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>());
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-11
@@ -61,35 +61,48 @@
|
|||||||
return pixels
|
return pixels
|
||||||
}
|
}
|
||||||
|
|
||||||
function paint(image) {
|
// paints an element's blurhash as its background: an image's until it has loaded, anything else's (the placeholder of
|
||||||
if (image.dataset.blurhashPainted || (image.complete && image.naturalWidth))
|
// sensitive media) for good. A changed hash, or an image given another source, is painted again.
|
||||||
|
function paint(element) {
|
||||||
|
const hash = element.dataset.blurhash
|
||||||
|
const isImage = element.tagName === "IMG"
|
||||||
|
if (!hash || element.dataset.blurhashPainted === hash || (isImage && element.complete && element.naturalWidth))
|
||||||
return
|
return
|
||||||
image.dataset.blurhashPainted = "1"
|
element.dataset.blurhashPainted = hash
|
||||||
try {
|
try {
|
||||||
const pixels = decode(image.dataset.blurhash)
|
const pixels = decode(hash)
|
||||||
if (!pixels)
|
if (!pixels)
|
||||||
return
|
return
|
||||||
const canvas = document.createElement("canvas")
|
const canvas = document.createElement("canvas")
|
||||||
canvas.width = size
|
canvas.width = size
|
||||||
canvas.height = size
|
canvas.height = size
|
||||||
canvas.getContext("2d").putImageData(new ImageData(pixels, size, size), 0, 0)
|
canvas.getContext("2d").putImageData(new ImageData(pixels, size, size), 0, 0)
|
||||||
image.style.backgroundImage = `url(${canvas.toDataURL()})`
|
element.style.backgroundImage = `url(${canvas.toDataURL()})`
|
||||||
image.style.backgroundSize = "cover"
|
element.style.backgroundSize = "cover"
|
||||||
image.addEventListener("load", () => image.style.removeProperty("background-image"), { once: true })
|
if (isImage)
|
||||||
|
element.addEventListener("load", () => element.style.removeProperty("background-image"), { once: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
window.logFromJs?.(e.message, "blurhash")
|
window.logFromJs?.(e.message, "blurhash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scan = (root) => root.querySelectorAll("img[data-blurhash]").forEach(paint)
|
const scan = (root) => root.querySelectorAll("[data-blurhash]").forEach(paint)
|
||||||
new MutationObserver(records => {
|
new MutationObserver(records => {
|
||||||
for (const record of records)
|
for (const record of records) {
|
||||||
|
if (record.type === "attributes") {
|
||||||
|
// another picture in the same element (the viewer's previous and next): painted anew
|
||||||
|
if (record.attributeName === "src")
|
||||||
|
delete record.target.dataset.blurhashPainted
|
||||||
|
paint(record.target)
|
||||||
|
continue
|
||||||
|
}
|
||||||
for (const node of record.addedNodes)
|
for (const node of record.addedNodes)
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
if (node.matches("img[data-blurhash]"))
|
if (node.matches("[data-blurhash]"))
|
||||||
paint(node)
|
paint(node)
|
||||||
scan(node)
|
scan(node)
|
||||||
}
|
}
|
||||||
}).observe(document.documentElement, { childList: true, subtree: true })
|
}
|
||||||
|
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-blurhash", "src"] })
|
||||||
scan(document)
|
scan(document)
|
||||||
})()
|
})()
|
||||||
@@ -169,6 +169,17 @@ window.revealActive = strip => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant)
|
// a phone: narrower than Tailwind's md, or held sideways (as short as the short: variant)
|
||||||
|
// the reader asked for less motion: what would move by itself (a gifv) waits for a tap
|
||||||
|
window.prefersReducedMotion = () => {
|
||||||
|
try {
|
||||||
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
window.logFromJs(e.message, "prefersReducedMotion")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.isMobileMedia = () => {
|
window.isMobileMedia = () => {
|
||||||
try {
|
try {
|
||||||
return window.matchMedia("(max-width: 767.98px), (max-height: 30rem)").matches
|
return window.matchMedia("(max-width: 767.98px), (max-height: 30rem)").matches
|
||||||
|
|||||||
Reference in new issue
Block a user