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
{
/// The Mastodon actions a status has, by their route segment.
public enum StatusAction
{
Favourite,
Unfavourite,
Reblog,
Unreblog,
Bookmark,
Unbookmark,
Pin,
Unpin
}
///
/// 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.
///
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// 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.
public async Task 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(cancellationToken) };
var status = await response.MastodonReadFromJsonAsync(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 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();
}
/// The persona's own text of a post, to edit it; null when it cannot be read.
public async Task 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(cancellationToken) : null;
}
/// A post's versions, the oldest first and the one showing last; [] when they cannot be read.
public async Task> 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>(cancellationToken) ?? [];
}
/// Who liked a post, or boosted it, as far as PrivaPub knows; [] when it cannot be read.
public async Task> 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>(cancellationToken) ?? [];
}
/// 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.
public async Task 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(cancellationToken) };
}
/// The persona's posts waiting for their time, soonest first.
public async Task> 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>(cancellationToken) ?? []).OrderBy(s => s.ScheduledAt)];
}
/// Moves a scheduled post to another time; Data is the ScheduledStatus.
public async Task 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(cancellationToken) };
}
/// Drops a scheduled post: it is never published.
public async Task 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();
}
/// A located or group post: text only, through /clientapi/post/insert.
async Task 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(cancellationToken) };
}
public async Task 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);
}
/// Reacts with an emoji (or takes the reaction back); Data is the updated Status.
public async Task 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);
}
/// 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.
public async Task 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);
}
/// Votes in a poll as the persona (each option by its index); Data is the updated Poll.
public async Task Vote(string pollId, IReadOnlyCollection 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(cancellationToken) };
}
/// Deletes the persona's own status.
public async Task 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);
}
/// Deletes the persona's own located post: through /clientapi, since the Mastodon API looks past located posts.
public async Task 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 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(cancellationToken);
await LogFailure(response, nameof(Get), cancellationToken);
return null;
}
/// The thread around a status, or null when it cannot be read.
public async Task 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(cancellationToken);
await LogFailure(response, nameof(Context), cancellationToken);
return null;
}
/// Mutes or blocks an account for the current persona only (block federates, as PrivaPub's owner decided).
public async Task 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(cancellationToken) };
}
async Task StatusResult(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var status = await response.MastodonReadFromJsonAsync(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);
}
}
}