- A community's announces resolve, as FEDERATION.md and ROADMAP always said Announce ids do. GroupDistributor keeps
each one it sends (GroupAnnouncement, unique by id). /grunts serves it while the post it carries is shown and 410
after, and also serves the announce-{postId} ids the group outbox lists, which now keep a stable `published`
instead of the time of the fetch.
- A persona's boost points at the boosted post: its `url` in the Mastodon API is the boosted post's page, and a browser
following the boost's id is redirected there instead of getting JSON.
- /tags/{tag}, where every Hashtag link we send points, is now a public page of this server's public posts with that
tag, with the same strict CSP and noindex as the profile pages.
- Grouped notifications (/api/v2/notifications, its unread count, a group, its accounts and dismiss). We advertise
api_versions.mastodon = 7 so clients show quotes, and clients that trust it call these; they answered 404. Likes and
boosts of one post group together, as do follows within an hour. The version string stays 4.2.0 until streaming
and Web Push exist.
- A remote account's follower, following and post counts are what its server publishes. AccountCountsJob reads its
collections' totalItems from its own origin, signed by the instance actor, at most daily and only after the account
was fetched, never when someone looks. They used to be 0.
- The other ids that do not resolve are documented as such: Update, Delete, EmojiReact, QuoteRequest and its answers,
Flag, Ignore and poll votes.
676 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
592 lines
27 KiB
C#
592 lines
27 KiB
C#
using MongoDB.Entities;
|
||
|
||
using PrivaPub.Api.Mastodon.Entities;
|
||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||
using PrivaPub.Domain.Media;
|
||
using PrivaPub.Federation.Actors;
|
||
using PrivaPub.Federation.Rendering;
|
||
using PrivaPub.Models.Federation;
|
||
using PrivaPub.Models.Group;
|
||
using PrivaPub.Domain.Privacy;
|
||
using PrivaPub.Federation.Objects;
|
||
using PrivaPub.Models.Post;
|
||
using PrivaPub.Domain.Statuses;
|
||
using PrivaPub.Models.Social;
|
||
using PrivaPub.Models.User;
|
||
using PrivaPub.StaticServices;
|
||
|
||
using System.Globalization;
|
||
using System.Net;
|
||
|
||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||
using PostEntity = PrivaPub.Models.Post.Post;
|
||
|
||
namespace PrivaPub.Api.Mastodon.Mappers
|
||
{
|
||
public class MastodonMapper
|
||
{
|
||
readonly DbEntities _dbEntities;
|
||
readonly ILocalActorService _localActors;
|
||
readonly IMediaProxy _proxy;
|
||
|
||
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default)
|
||
{
|
||
_dbEntities = dbEntities;
|
||
_localActors = localActors;
|
||
_proxy = proxy;
|
||
}
|
||
|
||
string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url);
|
||
|
||
public string ProxiedUrl(string url) => Proxied(url);
|
||
|
||
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
|
||
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
|
||
|
||
public async Task<Account> Local(LocalActor actor, bool withSource, CancellationToken token)
|
||
{
|
||
var followers = await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted, token);
|
||
var following = actor.Kind == LocalActorKind.Person
|
||
? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token)
|
||
: 0;
|
||
var latest = await _dbEntities.Posts
|
||
.Match(Counted.By(actor))
|
||
.Sort(p => p.ID, Order.Descending)
|
||
.Limit(1)
|
||
.ExecuteFirstAsync(token);
|
||
var statuses = latest == default ? 0 : await Counted.PostsBy(actor, token);
|
||
|
||
var account = new Account
|
||
{
|
||
Id = actor.Id,
|
||
Username = actor.UserName,
|
||
Acct = actor.UserName,
|
||
DisplayName = actor.Name ?? actor.UserName,
|
||
Locked = actor.ManuallyApprovesFollowers,
|
||
Bot = actor.IsBot,
|
||
Discoverable = actor.Discoverable,
|
||
Indexable = actor.Indexable,
|
||
Noindex = !actor.Indexable,
|
||
HideCollections = true,//follower and following lists are never shown to others, only their counts
|
||
Group = actor.Kind == LocalActorKind.Group,
|
||
CreatedAt = MastodonJson.Day(actor.Published),
|
||
Note = ActivityPubRenderer.Html(actor.Summary),
|
||
Url = actor.HtmlUrl,
|
||
Uri = actor.Uri,
|
||
Avatar = actor.PictureURL ?? MissingAvatar,
|
||
AvatarStatic = actor.PictureURL ?? MissingAvatar,
|
||
Header = actor.ThumbnailURL ?? MissingHeader,
|
||
HeaderStatic = actor.ThumbnailURL ?? MissingHeader,
|
||
FollowersCount = (int)followers,
|
||
FollowingCount = (int)following,
|
||
StatusesCount = (int)statuses,
|
||
LastStatusAt = latest == default ? default : latest.CreationDate.ToString("yyyy-MM-dd"),
|
||
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = ActivityPubRenderer.FieldValue(f.Value) }).ToList()
|
||
};
|
||
if (withSource)
|
||
account.Source = new Source
|
||
{
|
||
Privacy = actor.Settings.DefaultVisibility ?? "public",
|
||
Sensitive = actor.Settings.DefaultSensitive,
|
||
Language = actor.Settings.DefaultLanguage,
|
||
QuotePolicy = actor.Settings.QuotePolicy ?? QuotePolicies.Public,
|
||
Note = actor.Summary ?? string.Empty,
|
||
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
|
||
};
|
||
return account;
|
||
}
|
||
|
||
public Account Foreign(ForeignAvatar foreign) => new()
|
||
{
|
||
Id = foreign.ID,
|
||
Username = foreign.UserName,
|
||
Acct = $"{foreign.UserName}@{foreign.Domain}",
|
||
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
|
||
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
|
||
Group = foreign.AvatarType == AvatarType.Group,
|
||
Locked = foreign.IsLocked,
|
||
Discoverable = foreign.IsDiscoverable,
|
||
Indexable = foreign.IsIndexable,
|
||
Memorial = foreign.IsMemorial ? true : default(bool?),
|
||
CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt),
|
||
Emojis = Emojis(foreign.Emojis),
|
||
Fields = foreign.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||
AvatarDescription = foreign.PictureDescription ?? string.Empty,
|
||
HeaderDescription = foreign.HeaderDescription ?? string.Empty,
|
||
Note = foreign.Biography ?? string.Empty,
|
||
Url = foreign.Url ?? foreign.ActorURI,
|
||
Uri = foreign.ActorURI,
|
||
Avatar = Proxied(foreign.PictureURL) ?? MissingAvatar,
|
||
AvatarStatic = Proxied(foreign.PictureURL) ?? MissingAvatar,
|
||
Header = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
|
||
HeaderStatic = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
|
||
FollowersCount = foreign.FollowersCount ?? 0,//as its server publishes them (AccountCountsJob); 0 while unknown
|
||
FollowingCount = foreign.FollowingCount ?? 0,
|
||
StatusesCount = foreign.StatusesCount ?? 0
|
||
};
|
||
|
||
public async Task<Account> Account(string id, CancellationToken token) =>
|
||
(await Accounts(new[] { id }, token)).GetValueOrDefault(id);
|
||
|
||
public async Task<Dictionary<string, Account>> Accounts(IEnumerable<string> ids, CancellationToken token)
|
||
{
|
||
var wanted = ids.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
|
||
var accounts = new Dictionary<string, Account>();
|
||
if (wanted.Count == 0)
|
||
return accounts;
|
||
|
||
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
|
||
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
|
||
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
|
||
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
|
||
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token))
|
||
accounts[foreign.ID] = Foreign(foreign);
|
||
return accounts;
|
||
}
|
||
|
||
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
|
||
(await Statuses(new[] { post }, viewerId, token)).FirstOrDefault();
|
||
|
||
public Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token) =>
|
||
Statuses(posts, viewerId, nested: false, token);
|
||
|
||
async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, bool nested, CancellationToken token)
|
||
{
|
||
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
|
||
var originals = originalIds.Count == 0
|
||
? new Dictionary<string, PostEntity>()
|
||
: (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||
var all = posts.Concat(originals.Values).ToList();
|
||
|
||
var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList();
|
||
var mentionAccounts = mentionUris.Count == 0
|
||
? new Dictionary<string, ForeignAvatar>()
|
||
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
|
||
var accounts = await Accounts(all.Select(AuthorOf), token);
|
||
|
||
var ids = all.Select(p => p.ID).ToList();
|
||
var favourited = viewerId == default
|
||
? new HashSet<string>()
|
||
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
|
||
var bookmarked = viewerId == default
|
||
? new HashSet<string>()
|
||
: (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet();
|
||
var pinned = (await DB.Default.Find<Pin>().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet();
|
||
var reactions = (await DB.Default.Find<Reaction>().Match(r => ids.Contains(r.PostId)).ExecuteAsync(token))
|
||
.GroupBy(r => r.PostId)
|
||
.ToDictionary(g => g.Key, g => g.GroupBy(r => r.Emoji)
|
||
.Select(e => new EmojiReactionEntity
|
||
{
|
||
Name = e.Key,
|
||
Count = e.Count(),
|
||
Me = viewerId != default && e.Any(r => r.AccountId == viewerId),
|
||
Url = Proxied(e.First().EmojiURL),
|
||
StaticUrl = Proxied(e.First().EmojiURL)
|
||
})
|
||
.OrderByDescending(e => e.Count)
|
||
.ToList());
|
||
var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
|
||
var ownVotes = viewerId == default || pollIds.Count == 0
|
||
? new Dictionary<string, List<int>>()
|
||
: (await DB.Default.Find<PollVote>().Match(v => pollIds.Contains(v.PostId) && v.VoterAccountId == viewerId && v.IsLocalVoter).ExecuteAsync(token))
|
||
.GroupBy(v => v.PostId).ToDictionary(g => g.Key, g => g.Select(v => v.Choice).ToList());
|
||
var hidden = viewerId == default
|
||
? new HashSet<string>()
|
||
: await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token);
|
||
var reblogged = viewerId == default
|
||
? new HashSet<string>()
|
||
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||
.Select(p => p.ReblogOfPostId).ToHashSet();
|
||
|
||
Status Map(PostEntity post)
|
||
{
|
||
if (AuthorOf(post) is not { } author || !accounts.TryGetValue(author, out var account))
|
||
return default;//a post whose author we cannot show is left out, never a 500
|
||
var status = new Status
|
||
{
|
||
Id = post.ID,
|
||
Uri = post.ObjectURI,
|
||
Url = post.Url ?? post.ObjectURI,
|
||
Account = account,
|
||
InReplyToId = post.AnsweringToPostId,
|
||
InReplyToAccountId = post.InReplyToAccountId,
|
||
Content = Content(post),
|
||
CreatedAt = MastodonJson.Time(post.CreationDate),
|
||
EditedAt = post.EditedAt.HasValue ? MastodonJson.Time(post.EditedAt.Value) : default,
|
||
RepliesCount = post.RepliesCount,
|
||
ReblogsCount = post.ReblogsCount,
|
||
FavouritesCount = post.FavouritesCount,
|
||
Favourited = favourited.Contains(post.ID),
|
||
Reblogged = reblogged.Contains(post.ID),
|
||
Bookmarked = bookmarked.Contains(post.ID),
|
||
Pinned = pinned.Contains(post.ID),
|
||
Sensitive = post.HasContentWarning,
|
||
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
|
||
Visibility = Visibility(post.Visibility),
|
||
Language = post.Language,
|
||
MediaAttachments = post.Media.Count > 0 ? post.Media.Select(Media).ToList() : Playable(post),
|
||
Card = Card(post),
|
||
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
||
Emojis = Emojis(post.Emojis),
|
||
Privapub = Extension(post),
|
||
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
||
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
|
||
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
||
};
|
||
return status;
|
||
}
|
||
|
||
var localAuthors = all.Where(p => !p.IsFederatedCopy && p.GroupUserId != default).Select(p => p.GroupUserId).Distinct().ToList();
|
||
var followsAuthor = viewerId == default || localAuthors.Count == 0
|
||
? new HashSet<string>()
|
||
: await FollowedLocalAuthors(viewerId, localAuthors, token);
|
||
var quotedIds = nested ? new List<string>() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList();
|
||
var quotedPosts = new List<PostEntity>();
|
||
foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
|
||
if (quotedPost.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quotedPost, viewerId, token))
|
||
quotedPosts.Add(quotedPost);
|
||
var quotedStatuses = quotedPosts.Count == 0
|
||
? new Dictionary<string, Status>()
|
||
: (await Statuses(quotedPosts, viewerId, nested: true, token)).ToDictionary(q => q.Id);
|
||
|
||
void Quote(Status status, PostEntity post)
|
||
{
|
||
status.QuotesCount = post.QuotesCount;
|
||
status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId));
|
||
if (post.QuoteState == QuoteState.None)
|
||
return;
|
||
var state = post.QuoteState.ToString().ToLowerInvariant();
|
||
var quoted = post.QuotedPostId != default && quotedStatuses.TryGetValue(post.QuotedPostId, out var shown) ? shown : default;
|
||
status.Quote = nested
|
||
? new QuoteEntity { State = state, QuotedStatusId = post.QuotedPostId }
|
||
: new QuoteEntity { State = state, QuotedStatus = post.QuoteState == QuoteState.Accepted ? quoted : default, QuotedStatusId = post.QuotedPostId };
|
||
if (!nested && post.QuoteState == QuoteState.Accepted && quoted != default)
|
||
status.Content = WithoutQuoteFallback(status.Content);
|
||
}
|
||
|
||
var mapped = new List<Status>();
|
||
foreach (var post in posts)
|
||
{
|
||
if (!VisibilityPolicy.Shown(post) || hidden.Contains(post.ActorURI) || post.ReblogOfPostId != default && originals.TryGetValue(post.ReblogOfPostId, out var hiddenOriginal)
|
||
&& hidden.Contains(hiddenOriginal.ActorURI))
|
||
continue;
|
||
var status = Map(post);
|
||
if (status == default)
|
||
continue;
|
||
Quote(status, post);
|
||
if (post.ReblogOfPostId != default)
|
||
{
|
||
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner)
|
||
continue;
|
||
Quote(inner, original);
|
||
status.Reblog = inner;
|
||
status.Url = inner.Url;//a boost has no page of its own; /grunts would answer JSON to a browser
|
||
status.Content = string.Empty;
|
||
status.Reblogged = reblogged.Contains(original.ID);
|
||
}
|
||
mapped.Add(status);
|
||
}
|
||
return mapped;
|
||
}
|
||
|
||
public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId;
|
||
|
||
static readonly AngleSharp.Html.Parser.HtmlParser Fragments = new();
|
||
|
||
public static string WithoutQuoteFallback(string html)
|
||
{
|
||
if (string.IsNullOrEmpty(html) || !html.Contains("quote-inline", StringComparison.Ordinal))
|
||
return html;
|
||
var document = Fragments.ParseDocument($"<body>{html}</body>");
|
||
foreach (var fallback in document.QuerySelectorAll(".quote-inline").ToList())
|
||
fallback.Remove();
|
||
return document.Body!.InnerHtml;
|
||
}
|
||
|
||
async Task<HashSet<string>> FollowedLocalAuthors(string viewerId, List<string> authorIds, CancellationToken token)
|
||
{
|
||
var uris = new Dictionary<string, string>();
|
||
foreach (var avatar in await _dbEntities.Avatars.Match(a => authorIds.Contains(a.ID)).ExecuteAsync(token))
|
||
uris[_localActors.FromAvatar(avatar).Uri] = avatar.ID;
|
||
var targets = uris.Keys.ToList();
|
||
return (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && targets.Contains(f.TargetActorURI) && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||
.Select(f => uris[f.TargetActorURI]).ToHashSet();
|
||
}
|
||
|
||
static QuoteApprovalEntity LocalApproval(PostEntity post, string viewerId, bool viewerFollows)
|
||
{
|
||
var policy = ActivityPubRenderer.QuotableBy(post);
|
||
var everyone = policy switch { QuotePolicies.Public => new List<string> { "public" }, QuotePolicies.Followers => new List<string> { "followers" }, _ => new List<string>() };
|
||
var current = viewerId == default ? "unknown"
|
||
: viewerId == post.GroupUserId && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? "automatic"
|
||
: policy == QuotePolicies.Public || policy == QuotePolicies.Followers && viewerFollows ? "automatic"
|
||
: "denied";
|
||
return new QuoteApprovalEntity { Automatic = everyone, CurrentUser = current };
|
||
}
|
||
|
||
static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
|
||
{
|
||
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||
return new QuoteApprovalEntity();
|
||
if (post.QuotePolicy is not { } policy)
|
||
return new QuoteApprovalEntity { Automatic = new List<string> { "public" }, CurrentUser = viewerId == default ? "unknown" : "automatic" };
|
||
static List<string> Named(IEnumerable<string> who) => who.Select(w => Addressing.IsPublic(w) ? "public" : w.EndsWith("/followers") ? "followers" : "unsupported_policy").Distinct().ToList();
|
||
return new QuoteApprovalEntity
|
||
{
|
||
Automatic = Named(policy.Automatic),
|
||
Manual = Named(policy.Manual),
|
||
CurrentUser = viewerId == default ? "unknown" : policy.Automatic.Any(Addressing.IsPublic) ? "automatic" : policy.Manual.Any(Addressing.IsPublic) ? "manual" : "denied"
|
||
};
|
||
}
|
||
|
||
public static string Content(PostEntity post)
|
||
{
|
||
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
|
||
if (post.IsFederatedCopy && string.IsNullOrWhiteSpace(content) && !string.IsNullOrEmpty(post.Excerpt))
|
||
content = $"<p>{WebUtility.HtmlEncode(post.Excerpt)}</p>";
|
||
if (post.Event is { } happening && EventLine(happening) is { } line)
|
||
content = $"<p>{WebUtility.HtmlEncode(line)}</p>{content}";
|
||
var titled = !string.IsNullOrEmpty(post.Title) && (!post.IsFederatedCopy || post.ObjectType is not (null or "Note" or "Question"));
|
||
return titled
|
||
? $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}"
|
||
: content ?? string.Empty;
|
||
}
|
||
|
||
public static string EventLine(EventDetails happening)
|
||
{
|
||
if (happening.Start is not { } start)
|
||
return default;
|
||
var zone = Zone(happening.FloatingTime ? default : happening.TimeZone);
|
||
var local = zone == default ? start : TimeZoneInfo.ConvertTimeFromUtc(start, zone);
|
||
var when = local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||
if (happening.End is { } end && end > start)
|
||
{
|
||
var localEnd = zone == default ? end : TimeZoneInfo.ConvertTimeFromUtc(end, zone);
|
||
when += " – " + localEnd.ToString(localEnd.Date == local.Date ? "HH:mm" : "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||
}
|
||
when += happening.FloatingTime ? " (local time)" : zone == default ? " UTC" : $" ({happening.TimeZone})";
|
||
var where = happening.Places.Select(p => p.Name ?? p.Address).FirstOrDefault(p => p != default)
|
||
?? (happening.OnlineURL != default ? "online" : default);
|
||
return where == default ? when : $"{when} · {where}";
|
||
}
|
||
|
||
static TimeZoneInfo Zone(string id)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(id))
|
||
return default;
|
||
try
|
||
{
|
||
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||
}
|
||
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||
{
|
||
return default;
|
||
}
|
||
}
|
||
|
||
public PollEntity Poll(PostEntity post, string viewerId, IReadOnlyCollection<int> ownVotes)
|
||
{
|
||
if (post.Poll is not { } poll)
|
||
return default;
|
||
var expired = PollService.Ended(poll);
|
||
var hidden = poll.HideTotals && !expired && post.GroupUserId != viewerId;
|
||
return new PollEntity
|
||
{
|
||
Id = post.ID,
|
||
ExpiresAt = poll.ExpiresAt is { } ends ? MastodonJson.Time(ends) : default,
|
||
Expired = expired,
|
||
Multiple = poll.Multiple,
|
||
VotesCount = poll.Options.Sum(o => o.Votes),
|
||
VotersCount = poll.Multiple ? poll.VotersCount : poll.VotersCount ?? poll.Options.Sum(o => o.Votes),
|
||
Voted = viewerId == default ? default(bool?) : ownVotes?.Count > 0 || post.GroupUserId == viewerId,
|
||
OwnVotes = viewerId == default ? default : ownVotes?.ToList() ?? new List<int>(),
|
||
Options = poll.Options.Select(o => new PollOptionEntity { Title = o.Title, VotesCount = hidden ? default(int?) : o.Votes }).ToList(),
|
||
Emojis = Emojis(post.Emojis.Where(e => poll.Options.Any(o => o.Title.Contains($":{e.Shortcode}:"))))
|
||
};
|
||
}
|
||
|
||
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
|
||
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
|
||
|
||
PrivaPubStatus Extension(PostEntity post) => new()
|
||
{
|
||
ObjectType = post.ObjectType,
|
||
Title = post.IsFederatedCopy ? post.Title : default,
|
||
Excerpt = post.Excerpt,
|
||
Cover = Proxied(post.CoverURL),
|
||
Source = post.Source == default ? default : new PrivaPubSource { Content = post.Source.Content, MediaType = post.Source.MediaType },
|
||
Link = post.Link == default ? default : new PrivaPubLink
|
||
{
|
||
Url = post.Link.Href, Title = post.Link.Title, Description = post.Link.Description, Image = Proxied(post.Link.ImageURL), FromPublisher = post.Link.FromPublisher
|
||
},
|
||
Video = post.Video == default ? default : new PrivaPubVideo
|
||
{
|
||
Duration = post.Video.DurationSeconds,
|
||
Variants = post.Video.Variants.Select(Variant).ToList(),
|
||
IsLive = post.Video.IsLive,
|
||
State = post.Video.State,
|
||
Views = post.Video.Views,
|
||
CommentsPolicy = post.Video.CommentsPolicy,
|
||
DownloadEnabled = post.Video.DownloadEnabled,
|
||
Licence = post.Video.Licence,
|
||
Category = post.Video.Category,
|
||
Channel = post.Video.ChannelURI,
|
||
Captions = post.Video.Captions.Select(c => new PrivaPubCaption { Language = c.Language, Url = Proxied(c.Url) }).ToList()
|
||
},
|
||
Audio = post.Audio == default ? default : new PrivaPubAudio
|
||
{
|
||
Duration = post.Audio.DurationSeconds,
|
||
Variants = post.Audio.Variants.Select(Variant).ToList(),
|
||
Album = post.Audio.Album,
|
||
Position = post.Audio.Position,
|
||
Licence = post.Audio.Licence
|
||
},
|
||
Event = post.Event == default ? default : new PrivaPubEvent
|
||
{
|
||
Start = post.Event.Start is { } start ? MastodonJson.Time(start) : default,
|
||
End = post.Event.End is { } end ? MastodonJson.Time(end) : default,
|
||
TimeZone = post.Event.TimeZone,
|
||
Floating = post.Event.FloatingTime,
|
||
Places = post.Event.Places.Select(p => new PrivaPubPlace { Name = p.Name, Address = p.Address, Latitude = p.Latitude, Longitude = p.Longitude, Url = p.Url }).ToList(),
|
||
OnlineUrl = post.Event.OnlineURL,
|
||
JoinMode = post.Event.JoinMode,
|
||
Participants = post.Event.ParticipantCount,
|
||
Capacity = post.Event.Capacity,
|
||
Status = post.Event.Status,
|
||
Category = post.Event.Category
|
||
},
|
||
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount }
|
||
};
|
||
|
||
PrivaPubVariant Variant(MediaVariant variant) => new()
|
||
{
|
||
Url = Proxied(variant.Url),
|
||
MediaType = variant.MediaType,
|
||
Width = variant.Width,
|
||
Height = variant.Height,
|
||
Fps = variant.Fps,
|
||
Size = variant.Size,
|
||
Streams = variant.Streams
|
||
};
|
||
|
||
List<MediaAttachment> Playable(PostEntity post)
|
||
{
|
||
var (variant, kind, duration) = post.Video is { } video ? (BestVideo(video.Variants), "video", video.DurationSeconds)
|
||
: post.Audio is { } audio ? (audio.Variants.FirstOrDefault(), "audio", audio.DurationSeconds)
|
||
: default;
|
||
if (variant == default)
|
||
return new List<MediaAttachment>();
|
||
return new List<MediaAttachment>
|
||
{
|
||
Media(new PostMedia
|
||
{
|
||
Id = new Guid(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(variant.Url))),
|
||
RemoteURL = variant.Url,
|
||
ContentType = variant.MediaType ?? kind + "/*",
|
||
RemotePreviewURL = post.CoverURL,
|
||
Width = variant.Width,
|
||
Height = variant.Height,
|
||
DurationSeconds = duration,
|
||
Description = post.Title
|
||
})
|
||
};
|
||
}
|
||
|
||
public static MediaVariant BestVideo(IReadOnlyCollection<MediaVariant> variants)
|
||
{
|
||
var playable = variants.Where(v => v.MediaType?.StartsWith("video/") == true && (v.Streams.Count == 0 || v.Streams.Contains("audio") && v.Streams.Contains("video"))).ToList();
|
||
return playable.Where(v => v.Height <= 720).OrderByDescending(v => v.Height).FirstOrDefault()
|
||
?? playable.OrderBy(v => v.Height ?? int.MaxValue).FirstOrDefault();
|
||
}
|
||
|
||
PreviewCard Card(PostEntity post)
|
||
{
|
||
if (post.Link is { } link)
|
||
return Card(link.Href, link.Title ?? post.Title, link.Description ?? (post.Media.Count == 0 ? post.Excerpt : default), link.ImageURL);
|
||
if (post.IsFederatedCopy && post.ObjectType is "Article" or "Event" && post.Url != default && post.Media.Count == 0
|
||
|| post.IsFederatedCopy && post.ObjectType is "Video" or "Audio" && post.Url != default && post.Media.Count == 0 && Playable(post).Count == 0)
|
||
return Card(post.Url, post.Title, post.Excerpt, post.CoverURL);
|
||
return default;
|
||
}
|
||
|
||
PreviewCard Card(string url, string title, string description, string image)
|
||
{
|
||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||
return default;
|
||
return new PreviewCard
|
||
{
|
||
Url = url,
|
||
Title = title ?? uri.Host,
|
||
Description = description ?? string.Empty,
|
||
ProviderName = uri.Host,
|
||
ProviderUrl = uri.GetLeftPart(UriPartial.Authority),
|
||
Image = Proxied(image)
|
||
};
|
||
}
|
||
|
||
public static string Visibility(PostVisibility visibility) => visibility switch
|
||
{
|
||
PostVisibility.Unlisted => "unlisted",
|
||
PostVisibility.FollowersOnly or PostVisibility.Circle => "private",
|
||
PostVisibility.Direct => "direct",
|
||
_ => "public"
|
||
};
|
||
|
||
StatusMention Mention(PostMention mention, IReadOnlyDictionary<string, ForeignAvatar> foreign)
|
||
{
|
||
if (mention.IsLocal)
|
||
{
|
||
var handle = mention.Handle?.TrimStart('@').Split('@')[0];
|
||
return new StatusMention { Id = mention.AccountId, Username = handle, Acct = handle, Url = mention.ActorURI };
|
||
}
|
||
if (!foreign.TryGetValue(mention.ActorURI, out var account))
|
||
return default;
|
||
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
|
||
}
|
||
|
||
static Dictionary<string, object> Meta(PostMedia media)
|
||
{
|
||
var original = new Dictionary<string, object>();
|
||
if (media.Width > 0 && media.Height > 0)
|
||
{
|
||
original["width"] = media.Width;
|
||
original["height"] = media.Height;
|
||
original["size"] = $"{media.Width}x{media.Height}";
|
||
original["aspect"] = (double)media.Width.Value / media.Height.Value;
|
||
}
|
||
if (media.DurationSeconds is { } seconds)
|
||
original["duration"] = seconds;
|
||
var meta = new Dictionary<string, object>();
|
||
if (original.Count > 0)
|
||
meta["original"] = original;
|
||
if (media.DurationSeconds is { } length)
|
||
meta["length"] = TimeSpan.FromSeconds(length).ToString(@"h\:mm\:ss\.ff", CultureInfo.InvariantCulture);
|
||
if (media.Focus is { Length: 2 })
|
||
meta["focus"] = new { x = media.Focus[0], y = media.Focus[1] };
|
||
return meta;
|
||
}
|
||
|
||
MediaAttachment Media(PostMedia media) => new()
|
||
{
|
||
Id = media.AttachmentId ?? media.Id.ToString("N"),
|
||
Type = media.ContentType switch
|
||
{
|
||
{ } type when type.StartsWith("image/gif") => "gifv",
|
||
{ } type when type.StartsWith("image/") => "image",
|
||
{ } type when type.StartsWith("video/") => "video",
|
||
{ } type when type.StartsWith("audio/") => "audio",
|
||
_ => "unknown"
|
||
},
|
||
Url = media.URL ?? Proxied(media.RemoteURL),
|
||
PreviewUrl = media.PreviewURL ?? Proxied(media.RemotePreviewURL)
|
||
?? (media.ContentType?.StartsWith("image/") == true || media.ContentType == default ? media.URL ?? Proxied(media.RemoteURL) : default),
|
||
RemoteUrl = media.RemoteURL,
|
||
Description = media.Description,
|
||
Blurhash = media.Blurhash,
|
||
Meta = Meta(media)
|
||
};
|
||
}
|
||
}
|