tests/e2e (Playwright for .NET 1.61.0 on xunit v3, the Chromium 1228 already cached, never downloaded, never in CI) runs the published app against SocialPub's pasture and its town: run.sh publishes it with the Pasture environment (wwwroot/appsettings.Pasture.json, published only then; the deploy fails if it ever ships) and serves it as https://decepub.test. The tests sign in through the real login page and persona chooser, check that every request goes only to decepub.test and privapub.test with the right credential on the right API, render one post of each kind from each platform as its author wrote it, and check every action on the other side of the wire (town.sh stored): favourites, boosts and replies with their undoing, follows from a profile, votes, reactions. Each test is also a cell of the town's report (out/client.jsonl). They found two PrivaPub bugs fixed there (a blocked author's status answered null; sensitive remote media invented a content warning) and a reply addressed to nobody. New in the app, all checked by those tests: - profiles at /account/{id}: header, bio, fields, counts, the account's posts as posts, posts and replies, or media, and follow, unfollow, cancel a request, mute and block (AccountService); post authors and notifications open them in the app; - search at /search: accounts, posts and hashtags, a handle or an address resolved by PrivaPub; - poll voting on the card, and emoji reactions shown and given with one tap; before a persona's first reaction or vote it is told once that both are public (PublicNoticeService, the owner's decision); - follow requests answered from their notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
181 lines
8.0 KiB
C#
181 lines
8.0 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
|
|
}
|
|
|
|
/// <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 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);
|
|
|
|
var mediaIds = new List<string>();
|
|
foreach (var media in form.Media)
|
|
{
|
|
var uploaded = await mediaService.Upload(media, cancellationToken);
|
|
if (uploaded.IsInvalid)
|
|
return uploaded;
|
|
mediaIds.Add(((MediaAttachment)uploaded.Data).Id);
|
|
}
|
|
|
|
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,
|
|
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
|
Visibility = form.Visibility,
|
|
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
|
|
}, cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
|
return new WebResult { Data = FeedPost.Of(status) };
|
|
}
|
|
|
|
/// <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>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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|