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
312 lines
16 KiB
C#
312 lines
16 KiB
C#
using System.Globalization;
|
|
using System.Net;
|
|
|
|
using collAnon.Client.Services;
|
|
|
|
using decePubClient.Extensions;
|
|
using decePubClient.Models;
|
|
using decePubClient.Models.Mastodon;
|
|
using decePubClient.Models.Types;
|
|
|
|
using PrivaPub.ClientModels;
|
|
using PrivaPub.ClientModels.Post;
|
|
|
|
namespace decePubClient.Services
|
|
{
|
|
/// <summary>The Mastodon actions a status has, by their route segment.</summary>
|
|
public enum StatusAction
|
|
{
|
|
Favourite,
|
|
Unfavourite,
|
|
Reblog,
|
|
Unreblog,
|
|
Bookmark,
|
|
Unbookmark,
|
|
Pin,
|
|
Unpin
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writing as the current persona: publishing (through the Mastodon API, or /clientapi for located and group posts),
|
|
/// favourites, boosts, bookmarks, deleting, threads, and muting or blocking an author. Results are WebResults whose
|
|
/// Data is the updated Status (a FeedPost when publishing); nothing throws.
|
|
/// </summary>
|
|
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
|
{
|
|
/// <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)
|
|
{
|
|
if (form.IsLocated || form.GroupId is not null)
|
|
return await PublishThroughClientApi(form, personaId, cancellationToken);
|
|
|
|
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;
|
|
var mediaIds = form.Media.Select(m => m.Id).ToList();
|
|
|
|
using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm
|
|
{
|
|
Status = form.Content ?? string.Empty,
|
|
MediaIds = mediaIds,
|
|
InReplyToId = form.InReplyToId,
|
|
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
|
Visibility = form.Visibility,
|
|
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
|
ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture),
|
|
Poll = form.HasPoll ? new PollForm { Options = [.. form.PollOptions.Select(o => o.Trim()).Where(o => o.Length > 0)], ExpiresIn = form.PollExpiresIn, Multiple = form.PollMultiple } : null,
|
|
QuotedStatusId = form.QuotedStatusId
|
|
}, cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
if (form.ScheduledAtUtc is not null)
|
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
|
|
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
|
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>
|
|
public async Task<StatusSource> Source(string statusId, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_StatusSource(statusId), cancellationToken: cancellationToken);
|
|
return response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<StatusSource>(cancellationToken) : null;
|
|
}
|
|
|
|
/// <summary>A post's versions, the oldest first and the one showing last; [] when they cannot be read.</summary>
|
|
public async Task<List<StatusEdit>> History(string statusId, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_StatusHistory(statusId), cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(History)}", cancellationToken);
|
|
return [];
|
|
}
|
|
return await response.MastodonReadFromJsonAsync<List<StatusEdit>>(cancellationToken) ?? [];
|
|
}
|
|
|
|
/// <summary>Who liked a post, or boosted it, as far as PrivaPub knows; [] when it cannot be read.</summary>
|
|
public async Task<List<Account>> InteractedBy(string statusId, bool boosts, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Get(boosts ? APIs.Mastodon.GET_M_RebloggedBy(statusId) : APIs.Mastodon.GET_M_FavouritedBy(statusId),
|
|
["limit=80"], cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(InteractedBy)}", cancellationToken);
|
|
return [];
|
|
}
|
|
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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
|
|
{
|
|
Status = form.Content ?? string.Empty,
|
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
|
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
|
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);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Status>(cancellationToken) };
|
|
}
|
|
|
|
/// <summary>The persona's posts waiting for their time, soonest first.</summary>
|
|
public async Task<List<ScheduledStatus>> Scheduled(CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_ScheduledStatuses, ["limit=40"], cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(Scheduled)}", cancellationToken);
|
|
return [];
|
|
}
|
|
return [.. (await response.MastodonReadFromJsonAsync<List<ScheduledStatus>>(cancellationToken) ?? []).OrderBy(s => s.ScheduledAt)];
|
|
}
|
|
|
|
/// <summary>Moves a scheduled post to another time; Data is the ScheduledStatus.</summary>
|
|
public async Task<WebResult> Reschedule(string scheduledId, DateTime atUtc, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Put(APIs.Mastodon.PUT_M_ScheduledStatus(scheduledId),
|
|
new ScheduleForm { ScheduledAt = atUtc.ToString("o", CultureInfo.InvariantCulture) }, cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
|
|
}
|
|
|
|
/// <summary>Drops a scheduled post: it is never published.</summary>
|
|
public async Task<WebResult> Unschedule(string scheduledId, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_ScheduledStatus(scheduledId), cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
return new WebResult();
|
|
}
|
|
|
|
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
|
|
async Task<WebResult> PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken)
|
|
{
|
|
if (form.IsLocated && (form.Latitude is null || form.Longitude is null))
|
|
return new WebResult().Invalidate(localizer["A located post needs this device's position."]);
|
|
|
|
using var response = await httpService.Post(APIs.ClientApi.POST_InsertPost, new InsertPostForm
|
|
{
|
|
AvatarId = personaId,
|
|
Text = form.Content ?? string.Empty,
|
|
GroupId = form.GroupId,
|
|
AnsweringToPostId = form.InReplyToId,
|
|
HasContentWarning = form.HasContentWarning,
|
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
|
Visibility = form.MessageType switch
|
|
{
|
|
MessageType.FollowersOnly => "followersonly",
|
|
MessageType.Unlisted => "unlisted",
|
|
_ => "public"
|
|
},
|
|
Latitude = form.IsLocated ? form.Latitude : null,
|
|
Longitude = form.IsLocated ? form.Longitude : null,
|
|
RangeKm = form.IsLocated ? form.RangeKm : null
|
|
}, cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewPost>(cancellationToken) };
|
|
}
|
|
|
|
public async Task<WebResult> Act(string statusId, StatusAction action, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_StatusAction(statusId, action.ToString().ToLowerInvariant()),
|
|
cancellationToken: cancellationToken);
|
|
return await StatusResult(response, cancellationToken);
|
|
}
|
|
|
|
/// <summary>Reacts with an emoji (or takes the reaction back); Data is the updated Status.</summary>
|
|
public async Task<WebResult> React(string statusId, string emoji, bool add, CancellationToken cancellationToken = default)
|
|
{
|
|
var route = APIs.Mastodon.M_Reaction(statusId, emoji);
|
|
using var response = add
|
|
? await httpService.Put(route, cancellationToken: cancellationToken)
|
|
: await httpService.Delete(route, cancellationToken: cancellationToken);
|
|
return await StatusResult(response, cancellationToken);
|
|
}
|
|
|
|
/// <summary>Joins a remote event as the persona (its server is told, and shows who takes part), or leaves it; Data is
|
|
/// the updated Status, whose event says how the participation stands.</summary>
|
|
public async Task<WebResult> Participate(string statusId, bool join, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Post(join ? APIs.PrivaPub.POST_M_Join(statusId) : APIs.PrivaPub.POST_M_Leave(statusId),
|
|
cancellationToken: cancellationToken);
|
|
return await StatusResult(response, cancellationToken);
|
|
}
|
|
|
|
/// <summary>Votes in a poll as the persona (each option by its index); Data is the updated Poll.</summary>
|
|
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> choices, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_PollVotes(pollId), new PollVoteForm { Choices = [.. choices] },
|
|
cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Poll>(cancellationToken) };
|
|
}
|
|
|
|
/// <summary>Deletes the persona's own status.</summary>
|
|
public async Task<WebResult> Delete(string statusId, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Status(statusId), cancellationToken: cancellationToken);
|
|
return await StatusResult(response, cancellationToken);
|
|
}
|
|
|
|
/// <summary>Deletes the persona's own located post: through /clientapi, since the Mastodon API looks past located posts.</summary>
|
|
public async Task<WebResult> DeleteLocated(string postId, string personaId, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Post(APIs.ClientApi.POST_DeletePost, new DeletePostForm { AvatarId = personaId, PostId = postId },
|
|
cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
return new WebResult();
|
|
}
|
|
|
|
public async Task<Status> Get(string statusId, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_Status(statusId), cancellationToken: cancellationToken);
|
|
if (response.IsSuccessStatusCode)
|
|
return await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
|
|
|
await LogFailure(response, nameof(Get), cancellationToken);
|
|
return null;
|
|
}
|
|
|
|
/// <summary>The thread around a status, or null when it cannot be read.</summary>
|
|
public async Task<StatusContext> Context(string statusId, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_Context(statusId), cancellationToken: cancellationToken);
|
|
if (response.IsSuccessStatusCode)
|
|
return await response.MastodonReadFromJsonAsync<StatusContext>(cancellationToken);
|
|
|
|
await LogFailure(response, nameof(Context), cancellationToken);
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Mutes or blocks an account for the current persona only (block federates, as PrivaPub's owner decided).</summary>
|
|
public async Task<WebResult> AccountAction(string accountId, string action, CancellationToken cancellationToken = default)
|
|
{
|
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, action), cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
|
|
}
|
|
|
|
async Task<WebResult> StatusResult(HttpResponseMessage response, CancellationToken cancellationToken)
|
|
{
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
|
return status is null
|
|
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
|
: new WebResult { Data = status };
|
|
}
|
|
|
|
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
|
{
|
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
if (result.StatusCode is not (int)HttpStatusCode.Gone)
|
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(StatusService)}.{caller}", cancellationToken);
|
|
}
|
|
}
|
|
}
|