One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi, picks a persona, and each persona gets its own Mastodon token in exchange for the JWT (PersonaExchange). HttpService picks credentials and JSON by route (JWT and camelCase for /clientapi, the persona token and snake_case for /api, nothing for /oauth), retries a refused persona token once, and records every request for the nerd stats. The feed page is three columns: navigation with a persona switcher, the feed with a tab per kind (home, local, federated, nearby, communities and circles, direct, notifications, favourites, bookmarks, hashtags), and a 3D globe that looks at the spherical mean of the posts in view and draws a pulse and an arc to a hovered one. Posts sit at their own place, their event's venue, the reader (located posts) or their author's server as PrivaPub publishes it. Cards favourite, boost, bookmark, reply, mute, block and delete; the composer posts with a content warning, visibility, media and alt text, or as a located post. Older pages load as the end nears; newer ones are polled while visible. Nerd stats in tooltips: provenance, author and server, place, media, counts, paging, the globe's camera and frame rate. The mock (Faker, MessagesService, the Message models and store) and the OIDC template leftovers are gone. Strings in English and Italian. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
160 lines
6.8 KiB
C#
160 lines
6.8 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>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);
|
|
}
|
|
}
|
|
}
|