LocalEmoji, a new collection (unique shortcode whatever its case, never renamed: the shortcode is its id elsewhere),
managed on /clientapi/admin/emoji/list|insert|update|delete and kept in the staff's log (emoji.insert, .update,
.delete). Its picture goes through IMediaService.EmojiImage: PNG, GIF, WebP or JPEG up to 256 KB, a PNG within 256 px
with alpha kept (an animated GIF or WebP stays an animated GIF, with a still PNG), keep=none, shrunk until it fits
GoToSocial's 100 KiB, held by ServerUse "emoji:<shortcode>" so the janitor and the audit leave it. LocalEmojis keeps the
offered ones in memory, reloaded on every change: GET /api/v1/custom_emojis is real; local posts (words, title, warning,
poll options, each revision) and profiles keep the emoji they name; Notes, Questions, actors and EmojiReacts carry
Mastodon's Emoji tag with its id, served as an Emoji at /emojis/{shortcode} (410 once deleted; the path awaits the
owner's confirmation); a persona reacts with one (Pleroma's reactions, announcements). A restore keeps the live emoji
as it keeps the settings, and trashes server pictures nothing holds. tools/pasture/scenarios/emoji.sh joins batch A.
Verified: built, and run locally against a scratch database (uploads of every type, animation kept, metadata gone,
refusals, rendering, reactions, history, announcements, delete and revive, backup and restore, media audit). The
pasture scenario and the sweep were not run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
524 lines
22 KiB
C#
524 lines
22 KiB
C#
using Markdig;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Domain.Content;
|
|
using PrivaPub.Domain.Media;
|
|
|
|
using System.Globalization;
|
|
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Federation.Rendering
|
|
{
|
|
public static class ActivityPubRenderer
|
|
{
|
|
public const string ActivityStreams = "https://www.w3.org/ns/activitystreams";
|
|
public const string Public = Addressing.Public;
|
|
public const string ContentWarning = "Content warning";
|
|
|
|
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().Build();
|
|
|
|
public static JsonArray Context() => new(
|
|
ActivityStreams,
|
|
"https://w3id.org/security/v1",
|
|
new JsonObject
|
|
{
|
|
["manuallyApprovesFollowers"] = "as:manuallyApprovesFollowers",
|
|
["sensitive"] = "as:sensitive",
|
|
["Hashtag"] = "as:Hashtag",
|
|
["alsoKnownAs"] = new JsonObject { ["@id"] = "as:alsoKnownAs", ["@type"] = "@id" },
|
|
["movedTo"] = new JsonObject { ["@id"] = "as:movedTo", ["@type"] = "@id" },
|
|
["toot"] = "http://joinmastodon.org/ns#",
|
|
["featured"] = new JsonObject { ["@id"] = "toot:featured", ["@type"] = "@id" },
|
|
["featuredTags"] = new JsonObject { ["@id"] = "toot:featuredTags", ["@type"] = "@id" },
|
|
["discoverable"] = "toot:discoverable",
|
|
["indexable"] = "toot:indexable",
|
|
["blurhash"] = "toot:blurhash",
|
|
["votersCount"] = "toot:votersCount",
|
|
["Emoji"] = "toot:Emoji",
|
|
["quote"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quote", ["@type"] = "@id" },
|
|
["quoteAuthorization"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quoteAuthorization", ["@type"] = "@id" },
|
|
["QuoteRequest"] = "https://w3id.org/fep/044f#QuoteRequest",
|
|
["QuoteAuthorization"] = "https://w3id.org/fep/044f#QuoteAuthorization",
|
|
["interactingObject"] = new JsonObject { ["@id"] = "gts:interactingObject", ["@type"] = "@id" },
|
|
["interactionTarget"] = new JsonObject { ["@id"] = "gts:interactionTarget", ["@type"] = "@id" },
|
|
["misskey"] = "https://misskey-hub.net/ns#",
|
|
["_misskey_quote"] = "misskey:_misskey_quote",
|
|
["fedibird"] = "http://fedibird.com/ns#",
|
|
["quoteUri"] = "fedibird:quoteUri",
|
|
["quoteUrl"] = "as:quoteUrl",
|
|
["gts"] = "https://gotosocial.org/ns#",
|
|
["interactionPolicy"] = new JsonObject { ["@id"] = "gts:interactionPolicy", ["@type"] = "@id" },
|
|
["canQuote"] = new JsonObject { ["@id"] = "gts:canQuote", ["@type"] = "@id" },
|
|
["automaticApproval"] = new JsonObject { ["@id"] = "gts:automaticApproval", ["@type"] = "@id" },
|
|
["manualApproval"] = new JsonObject { ["@id"] = "gts:manualApproval", ["@type"] = "@id" },
|
|
["LikeRequest"] = "gts:LikeRequest",
|
|
["ReplyRequest"] = "gts:ReplyRequest",
|
|
["AnnounceRequest"] = "gts:AnnounceRequest",
|
|
["LikeAuthorization"] = "gts:LikeApproval",
|
|
["ReplyAuthorization"] = "gts:ReplyAuthorization",
|
|
["AnnounceAuthorization"] = "gts:AnnounceAuthorization",
|
|
["likeAuthorization"] = new JsonObject { ["@id"] = "gts:likeAuthorization", ["@type"] = "@id" },
|
|
["replyAuthorization"] = new JsonObject { ["@id"] = "gts:replyAuthorization", ["@type"] = "@id" },
|
|
["replyApproval"] = new JsonObject { ["@id"] = "toot:replyApproval", ["@type"] = "@id" },
|
|
["announceAuthorization"] = new JsonObject { ["@id"] = "gts:announceAuthorization", ["@type"] = "@id" },
|
|
["litepub"] = "http://litepub.social/ns#",
|
|
["EmojiReact"] = "litepub:EmojiReact",
|
|
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
|
|
["schema"] = "http://schema.org#",
|
|
["PropertyValue"] = "schema:PropertyValue",
|
|
["value"] = "schema:value",
|
|
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger",
|
|
["lemmy"] = "https://join-lemmy.org/ns#",
|
|
["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods",
|
|
// Smithereen's, which it compacts what it reads by: an allowedTo stays a plain string, as it reads them
|
|
["sm"] = "http://smithereen.software/ns#",
|
|
["wall"] = new JsonObject { ["@id"] = "sm:wall", ["@type"] = "@id" },
|
|
["privacySettings"] = "sm:privacySettings",
|
|
["wallPosting"] = "sm:wallPosting",
|
|
["wallPostVisibility"] = "sm:wallPostVisibility",
|
|
["allowedTo"] = "sm:allowedTo",
|
|
// FEP-521a keys and FEP-8b32 proofs, defined here rather than by the data-integrity and multikey contexts,
|
|
// which strict JSON-LD readers would have to fetch. JSON-LD 1.0 only: Smithereen's reader refuses the whole
|
|
// document over a 1.1 term (proof's @graph container there; our proofs are canonicalised as JSON, by JCS)
|
|
["assertionMethod"] = new JsonObject { ["@id"] = "sec:assertionMethod", ["@type"] = "@id", ["@container"] = "@set" },
|
|
["Multikey"] = "sec:Multikey",
|
|
["controller"] = new JsonObject { ["@id"] = "sec:controller", ["@type"] = "@id" },
|
|
["publicKeyMultibase"] = new JsonObject { ["@id"] = "sec:publicKeyMultibase", ["@type"] = "sec:multibase" },
|
|
["DataIntegrityProof"] = "sec:DataIntegrityProof",
|
|
["proof"] = new JsonObject { ["@id"] = "sec:proof", ["@type"] = "@id" },
|
|
["cryptosuite"] = new JsonObject { ["@id"] = "sec:cryptosuite", ["@type"] = "sec:cryptosuiteString" },
|
|
["proofValue"] = new JsonObject { ["@id"] = "sec:proofValue", ["@type"] = "sec:multibase" },
|
|
["proofPurpose"] = new JsonObject { ["@id"] = "sec:proofPurpose", ["@type"] = "@vocab" },
|
|
["verificationMethod"] = new JsonObject { ["@id"] = "sec:verificationMethod", ["@type"] = "@id" },
|
|
["implements"] = new JsonObject { ["@id"] = "https://w3id.org/fep/844e/implements", ["@type"] = "@id", ["@container"] = "@set" }
|
|
});
|
|
|
|
// what PrivaPub reads that a sender cannot tell from our documents (FEP-844e): RFC 9421 signatures, with RSA keys
|
|
static JsonArray Implements() => new(new JsonObject
|
|
{
|
|
["href"] = "https://datatracker.ietf.org/doc/html/rfc9421",
|
|
["name"] = "RFC-9421: HTTP Message Signatures"
|
|
});
|
|
|
|
public static string Html(string markdown) =>
|
|
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
|
|
|
|
public static string Timestamp(DateTime value) =>
|
|
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
|
|
|
public static string Day(DateTime value) =>
|
|
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture);
|
|
|
|
public static string TagUrl(string baseAddress, string tag) => $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}";
|
|
|
|
public static JsonObject Actor(LocalActor actor)
|
|
{
|
|
var document = new JsonObject
|
|
{
|
|
["@context"] = Context(),
|
|
["id"] = actor.Uri,
|
|
["type"] = actor.Kind switch
|
|
{
|
|
LocalActorKind.Group => "Group",
|
|
LocalActorKind.Application => "Application",
|
|
LocalActorKind.Reporter or LocalActorKind.Reader => "Service",
|
|
_ when actor.IsBot => "Service",
|
|
_ => "Person"
|
|
},
|
|
["preferredUsername"] = actor.UserName,
|
|
["name"] = actor.Name,
|
|
["summary"] = Html(actor.Summary),
|
|
["url"] = actor.IsServerActor ? actor.Uri : actor.HtmlUrl,
|
|
["inbox"] = actor.Inbox,
|
|
["outbox"] = actor.Outbox,
|
|
["followers"] = actor.Followers,
|
|
["following"] = actor.Following,
|
|
["featured"] = actor.Featured,
|
|
["featuredTags"] = actor.FeaturedTags,
|
|
["published"] = Day(actor.Published),
|
|
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
|
|
["discoverable"] = actor.Discoverable,
|
|
["indexable"] = actor.Indexable,
|
|
["webfinger"] = actor.Handle,
|
|
["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox },
|
|
["publicKey"] = new JsonObject
|
|
{
|
|
["id"] = actor.KeyId,
|
|
["owner"] = actor.Uri,
|
|
["publicKeyPem"] = Keys.ToSubjectPublicKeyInfoPem(actor.PublicKeyPem)
|
|
},
|
|
["attachment"] = new JsonArray(actor.Fields.Select(field => (JsonNode)new JsonObject
|
|
{
|
|
["type"] = "PropertyValue",
|
|
["name"] = field.Key,
|
|
["value"] = FieldValue(field.Value)
|
|
}).ToArray())
|
|
};
|
|
// a persona's wall (FEP-400e, owner decision 2026-10-06): its followers write on it, everyone reads it
|
|
if (actor.HasWall)
|
|
{
|
|
document["wall"] = actor.Wall;
|
|
document["privacySettings"] = new JsonObject
|
|
{
|
|
["wallPosting"] = new JsonObject { ["allowedTo"] = new JsonArray(actor.Followers) },
|
|
["wallPostVisibility"] = new JsonObject { ["allowedTo"] = new JsonArray(Public) }
|
|
};
|
|
}
|
|
if (actor is { Kind: LocalActorKind.Group, IsCircle: false })
|
|
{
|
|
document["attributedTo"] = actor.Wardens;
|
|
document["postingRestrictedToMods"] = actor.PostingRestrictedToModerators;
|
|
}
|
|
// what the server reads (FEP-844e): on the application actor itself, on any other as its generator
|
|
if (actor.Kind == LocalActorKind.Application)
|
|
document["implements"] = Implements();
|
|
else
|
|
document["generator"] = new JsonObject { ["type"] = "Application", ["implements"] = Implements() };
|
|
// its Ed25519 key (FEP-521a), which signs the proofs its activities carry (FEP-8b32)
|
|
if (!string.IsNullOrEmpty(actor.SigningKey))
|
|
document["assertionMethod"] = new JsonArray(new JsonObject
|
|
{
|
|
["id"] = actor.AssertionKeyId,
|
|
["type"] = "Multikey",
|
|
["controller"] = actor.Uri,
|
|
["publicKeyMultibase"] = Signing.IntegrityProofs.Multikey(Signing.IntegrityProofs.PublicKey(actor.SigningKey))
|
|
});
|
|
// the server's custom emoji its name, bio and fields use (R7), only when there are some
|
|
var emojis = LocalEmojis.Of(actor.Emojis);
|
|
if (emojis.Count > 0)
|
|
document["tag"] = new JsonArray(emojis.Select(e => (JsonNode)LocalEmojis.Tag(e, actor.BaseAddress)).ToArray());
|
|
if (!string.IsNullOrEmpty(actor.PictureURL))
|
|
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
|
|
if (!string.IsNullOrEmpty(actor.ThumbnailURL))
|
|
document["image"] = new JsonObject { ["type"] = "Image", ["url"] = actor.ThumbnailURL };
|
|
return document;
|
|
}
|
|
|
|
public static JsonObject Note(PostEntity post, LocalActor author, LocalActor group, string inReplyTo)
|
|
{
|
|
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
|
|
var (to, cc) = post.Visibility switch
|
|
{
|
|
PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)),
|
|
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
|
|
// Pleroma and Akkoma call a post private only if a `to` contains "/followers" or its cc is not empty, and ours
|
|
// are /groupies: with an empty cc every followers-only post would be a DM there. Naming them again says nothing new.
|
|
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions.Append(author.Followers).ToArray())),
|
|
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
|
|
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
|
|
};
|
|
if (group is { IsCircle: false })
|
|
cc.Add(group.Uri);
|
|
// a public reply names the author it answers in cc even when the @name was deleted from its text, as Pleroma does:
|
|
// GoToSocial keeps only what is addressed to someone there, and the reply already says whom it answers
|
|
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted && !string.IsNullOrEmpty(post.InReplyToActorURI)
|
|
&& !mentions.Any(m => m.GetValue<string>() == post.InReplyToActorURI))
|
|
cc.Add(post.InReplyToActorURI);
|
|
|
|
var note = NoteBody(post, author, to, cc, inReplyTo);
|
|
// a public thread names where its replies and its whole conversation are read (owner decision 2026-10-06):
|
|
// Mastodon, GoToSocial and Fedify complete threads from them
|
|
if (!post.IsFederatedCopy && group == default && !post.IsLocalOnly && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
|
{
|
|
note["replies"] = author.PostUri(post.ID) + "/replies";
|
|
if (!string.IsNullOrEmpty(post.ContextURI))
|
|
note["context"] = post.ContextURI;
|
|
}
|
|
if (group != default)
|
|
note["audience"] = group.Uri;
|
|
else if (!string.IsNullOrEmpty(post.AudienceURI))
|
|
note["audience"] = post.AudienceURI;
|
|
if (group is { IsCircle: false } && string.IsNullOrEmpty(inReplyTo))
|
|
{
|
|
note["type"] = "Page";
|
|
note["name"] = post.Title ?? Headline(post);
|
|
}
|
|
return note;
|
|
}
|
|
|
|
public static JsonObject DirectNote(PostEntity post, LocalActor author, IReadOnlyList<(string Uri, string Handle)> recipients,
|
|
string context)
|
|
{
|
|
var note = NoteBody(post, author, new JsonArray(recipients.Select(r => (JsonNode)r.Uri).ToArray()), new JsonArray(), post.InReplyToURI);
|
|
var named = post.Mentions.Select(m => m.ActorURI).ToHashSet();
|
|
var tags = note["tag"]!.AsArray();
|
|
foreach (var recipient in recipients.Where(r => !named.Contains(r.Uri)))
|
|
tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = recipient.Uri, ["name"] = "@" + recipient.Handle });
|
|
var unmentioned = recipients.Where(r => !named.Contains(r.Uri)).ToList();
|
|
// a ChatMessage (Pleroma's, which Lemmy 0.19 and Mbin take as their private messages) has one recipient and no
|
|
// mention of them in its text
|
|
if (post.AsChatMessage)
|
|
{
|
|
note["type"] = "ChatMessage";
|
|
note.Remove("cc");
|
|
}
|
|
else if (unmentioned.Count > 0)
|
|
note["content"] = "<p>" + string.Join(" ", unmentioned.Select(r =>
|
|
$"<span class=\"h-card\" translate=\"no\"><a href=\"{WebUtility.HtmlEncode(r.Uri)}\" class=\"u-url mention\">@<span>{WebUtility.HtmlEncode(r.Handle.Split('@')[0])}</span></a></span>"))
|
|
+ "</p>" + note["content"]!.GetValue<string>();
|
|
if (!string.IsNullOrEmpty(context))
|
|
note["context"] = context;
|
|
return note;
|
|
}
|
|
|
|
public static JsonObject UpdateOf(PostEntity post, LocalActor author, LocalActor group, string reason)
|
|
{
|
|
var note = post.Visibility == PostVisibility.Direct
|
|
? DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
|
|
: Note(post, author, group, post.InReplyToURI);
|
|
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
|
|
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
|
|
return new JsonObject
|
|
{
|
|
["@context"] = Context(),
|
|
["id"] = author.ActivityUri($"update-{post.ID}-{reason}"),
|
|
["type"] = "Update",
|
|
["actor"] = author.Uri,
|
|
["to"] = note["to"]!.DeepClone(),
|
|
["cc"] = note["cc"]!.DeepClone(),
|
|
["object"] = note
|
|
};
|
|
}
|
|
|
|
public static string QuotableBy(PostEntity post) =>
|
|
post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? post.LocalQuotePolicy ?? Models.User.QuotePolicies.Nobody : Models.User.QuotePolicies.Nobody;
|
|
|
|
static void AddQuote(JsonObject note, PostEntity post, ref string content)
|
|
{
|
|
if (string.IsNullOrEmpty(post.QuoteURI))
|
|
return;
|
|
var link = WebUtility.HtmlEncode(post.QuoteURI);
|
|
content += $"<p class=\"quote-inline\">RE: <a href=\"{link}\">{link}</a></p>";
|
|
if (post.QuoteByConsent)
|
|
note["quote"] = post.QuoteURI;
|
|
if (!string.IsNullOrEmpty(post.QuoteAuthorizationURI))
|
|
note["quoteAuthorization"] = post.QuoteAuthorizationURI;
|
|
note["_misskey_quote"] = post.QuoteURI;
|
|
note["quoteUri"] = post.QuoteURI;
|
|
note["quoteUrl"] = post.QuoteURI;
|
|
(note["tag"] as JsonArray)?.Add(new JsonObject
|
|
{
|
|
["type"] = "Link",
|
|
["mediaType"] = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
|
|
["href"] = post.QuoteURI,
|
|
["name"] = $"RE: {post.QuoteURI}"
|
|
});
|
|
}
|
|
|
|
static void AddPoll(JsonObject note, PostPoll poll)
|
|
{
|
|
note["type"] = "Question";
|
|
note[poll.Multiple ? "anyOf" : "oneOf"] = new JsonArray(poll.Options.Select(option => (JsonNode)new JsonObject
|
|
{
|
|
["type"] = "Note",
|
|
["name"] = option.Title,
|
|
["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = option.Votes }
|
|
}).ToArray());
|
|
if (poll.ExpiresAt is { } ends)
|
|
note["endTime"] = Timestamp(ends);
|
|
if (poll.ClosedAt is { } closed)
|
|
note["closed"] = Timestamp(closed);
|
|
if (poll.VotersCount is { } voters)
|
|
note["votersCount"] = voters;
|
|
}
|
|
|
|
static string Headline(PostEntity post)
|
|
{
|
|
var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " ");
|
|
text = WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, "\\s+", " ")).Trim();
|
|
return text.Length <= 100 ? text : text[..97] + "...";
|
|
}
|
|
|
|
static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo)
|
|
{
|
|
var content = post.ContentHtml ?? Html(post.Text);
|
|
if (!string.IsNullOrEmpty(post.Title))
|
|
content = $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
|
|
|
|
var note = new JsonObject
|
|
{
|
|
["id"] = author.PostUri(post.ID),
|
|
["type"] = "Note",
|
|
["attributedTo"] = author.Uri,
|
|
["content"] = content,
|
|
["published"] = Timestamp(post.CreationDate),
|
|
["url"] = author.PostHtmlUrl(post.ID),
|
|
["to"] = to,
|
|
["cc"] = cc,
|
|
["sensitive"] = post.HasContentWarning,
|
|
["tag"] = new JsonArray(post.Mentions
|
|
.Select(m => (JsonNode)new JsonObject { ["type"] = "Mention", ["href"] = m.ActorURI, ["name"] = MentionName(m) })
|
|
.Concat(post.Tags.Select(t => (JsonNode)new JsonObject
|
|
{
|
|
["type"] = "Hashtag",
|
|
["href"] = TagUrl(author.BaseAddress, t),
|
|
["name"] = "#" + t
|
|
}))
|
|
// this server's custom emoji its words, warning and poll options use, as they are now (R7); another server's
|
|
// post (a copy we hold) carries its own
|
|
.Concat(post.IsFederatedCopy ? Enumerable.Empty<JsonNode>()
|
|
: LocalEmojis.Of(post.Emojis).Select(e => (JsonNode)LocalEmojis.Tag(e, author.BaseAddress)))
|
|
.ToArray())
|
|
};
|
|
AddQuote(note, post, ref content);
|
|
if (!post.IsFederatedCopy)
|
|
note["interactionPolicy"] = new JsonObject
|
|
{
|
|
["canQuote"] = new JsonObject
|
|
{
|
|
["automaticApproval"] = new JsonArray((JsonNode)(QuotableBy(post) switch
|
|
{
|
|
Models.User.QuotePolicies.Public => Objects.Addressing.Public,
|
|
Models.User.QuotePolicies.Followers => author.Followers,
|
|
_ => author.Uri
|
|
}))
|
|
}
|
|
};
|
|
note["content"] = content;
|
|
if (!string.IsNullOrEmpty(post.Language))
|
|
note["contentMap"] = new JsonObject { [post.Language] = content };
|
|
if (post.Poll is { } poll)
|
|
AddPoll(note, poll);
|
|
var attachments = post.Media.Where(m => !string.IsNullOrEmpty(m.URL)).Select(m =>
|
|
{
|
|
var attachment = new JsonObject
|
|
{
|
|
["type"] = "Document",
|
|
["mediaType"] = m.ContentType,
|
|
["url"] = m.URL,
|
|
["name"] = m.Description
|
|
};
|
|
if (!string.IsNullOrEmpty(m.Blurhash))
|
|
attachment["blurhash"] = m.Blurhash;
|
|
if (FocalPoint.IsSound(m.Focus))
|
|
attachment["focalPoint"] = new JsonArray(m.Focus[0], m.Focus[1]);
|
|
if (m.Width.HasValue && m.Height.HasValue)
|
|
{
|
|
attachment["width"] = m.Width;
|
|
attachment["height"] = m.Height;
|
|
}
|
|
return (JsonNode)attachment;
|
|
}).ToArray();
|
|
if (attachments.Length > 0)
|
|
note["attachment"] = new JsonArray(attachments);
|
|
if (!string.IsNullOrEmpty(post.Title))
|
|
note["name"] = post.Title;
|
|
var summary = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ContentWarning : default);
|
|
if (!string.IsNullOrEmpty(summary))
|
|
{
|
|
note["summary"] = summary;
|
|
note["sensitive"] = true;
|
|
}
|
|
if (!string.IsNullOrEmpty(inReplyTo))
|
|
note["inReplyTo"] = inReplyTo;
|
|
// the parent's author let this reply in (GoToSocial's interaction policies)
|
|
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ApprovalURI))
|
|
note["replyAuthorization"] = post.ApprovalURI;
|
|
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ReplyApprovalURI))
|
|
note["replyApproval"] = post.ReplyApprovalURI;
|
|
if (post.EditedAt.HasValue)
|
|
note["updated"] = Timestamp(post.EditedAt.Value);
|
|
return note;
|
|
}
|
|
|
|
static string MentionName(PostMention mention)
|
|
{
|
|
var handle = mention.Handle?.TrimStart('@');
|
|
if (string.IsNullOrEmpty(handle))
|
|
return "@" + mention.ActorURI;
|
|
return "@" + (handle.Contains('@') ? handle : $"{handle}@{new Uri(mention.ActorURI).Authority}");
|
|
}
|
|
|
|
public static string FieldValue(string value)
|
|
{
|
|
var encoded = WebUtility.HtmlEncode(value ?? string.Empty);
|
|
return Uri.TryCreate(value, UriKind.Absolute, out var link) && link.Scheme is "https" or "http"
|
|
? $"<a href=\"{encoded}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{encoded}</a>"
|
|
: encoded;
|
|
}
|
|
|
|
public static JsonObject Create(LocalActor actor, JsonObject note, string activityId) => new()
|
|
{
|
|
["@context"] = Context(),
|
|
["id"] = actor.ActivityUri(activityId),
|
|
["type"] = "Create",
|
|
["actor"] = actor.Uri,
|
|
["published"] = note["published"]?.DeepClone(),
|
|
["to"] = note["to"]?.DeepClone(),
|
|
["cc"] = note["cc"]?.DeepClone(),
|
|
["object"] = note
|
|
};
|
|
|
|
public static JsonObject Announce(LocalActor group, string objectUri, string activityId, DateTime? published = default) => new()
|
|
{
|
|
["@context"] = ActivityStreams,
|
|
["id"] = group.ActivityUri(activityId),
|
|
["type"] = "Announce",
|
|
["actor"] = group.Uri,
|
|
["published"] = Timestamp(published ?? DateTime.UtcNow),
|
|
["to"] = new JsonArray(Public),
|
|
["cc"] = new JsonArray(group.Followers),
|
|
["object"] = objectUri
|
|
};
|
|
|
|
public static JsonObject Delete(LocalActor actor, string objectUri, string activityId, JsonArray to, JsonArray cc) => new()
|
|
{
|
|
["@context"] = ActivityStreams,
|
|
["id"] = actor.ActivityUri(activityId),
|
|
["type"] = "Delete",
|
|
["actor"] = actor.Uri,
|
|
["to"] = to,
|
|
["cc"] = cc,
|
|
["object"] = new JsonObject { ["id"] = objectUri, ["type"] = "Tombstone" }
|
|
};
|
|
|
|
public static JsonObject Accept(LocalActor actor, JsonNode follow, string activityId) => new()
|
|
{
|
|
["@context"] = ActivityStreams,
|
|
["id"] = actor.ActivityUri(activityId),
|
|
["type"] = "Accept",
|
|
["actor"] = actor.Uri,
|
|
["object"] = follow.DeepClone()
|
|
};
|
|
|
|
public static JsonObject OrderedCollection(string id, int totalItems, IEnumerable<JsonNode> items, string first = default)
|
|
{
|
|
var collection = new JsonObject
|
|
{
|
|
["@context"] = ActivityStreams,
|
|
["id"] = id,
|
|
["type"] = "OrderedCollection",
|
|
["totalItems"] = totalItems
|
|
};
|
|
if (first != default)
|
|
collection["first"] = first;
|
|
if (items != default)
|
|
collection["orderedItems"] = new JsonArray(items.ToArray());
|
|
return collection;
|
|
}
|
|
|
|
public static JsonObject OrderedCollectionPage(string id, string partOf, IEnumerable<JsonNode> items, string next, string prev)
|
|
{
|
|
var page = new JsonObject
|
|
{
|
|
["@context"] = Context(),
|
|
["id"] = id,
|
|
["type"] = "OrderedCollectionPage",
|
|
["partOf"] = partOf,
|
|
["orderedItems"] = new JsonArray(items.ToArray())
|
|
};
|
|
if (next != default)
|
|
page["next"] = next;
|
|
if (prev != default)
|
|
page["prev"] = prev;
|
|
return page;
|
|
}
|
|
}
|
|
}
|