Owner decision of 2026-10-06 (G-0009, FEP-400e). A persona's actor names its wall (…/graffiti, sm:wall) and, in
Smithereen's privacySettings, that its followers may write on it. A public post that is not a reply, sent with the wall
as its target by an account following the persona, is hosted: the persona is notified, it reaches the persona's and its
followers' homes, and the followers' servers and the author's are told with Add{Note}. The persona deletes it with
DELETE /api/v1/statuses/:id, which sends Remove{Note}; Smithereen deletes the post then. The wall lists the persona's
public posts that start a thread and what was written on it.
Elsewhere: an account's Add{Note} on its own wall shows the post to its followers here as on that wall (privapub.wall on
the status), and its Remove takes it away. An Add or Remove naming a collection of the account's own server PrivaPub
does not know has its document read again first, at most hourly: accounts kept before walls were read had none.
Checked live: Smithereen 40 checks (G-0009 closed); GoToSocial and Mastodon unchanged (121).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
240 lines
11 KiB
C#
240 lines
11 KiB
C#
using PrivaPub.Models.Post;
|
|
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
|
|
|
namespace PrivaPub.Federation.Objects
|
|
{
|
|
public sealed class NoteDocument
|
|
{
|
|
public string Id { get; init; }
|
|
public string Type { get; init; }
|
|
public JsonObject Raw { get; init; }
|
|
public string AttributedTo { get; init; }
|
|
public string ContentHtml { get; init; }
|
|
public string Title { get; init; }
|
|
public string SpoilerText { get; init; }
|
|
public string Excerpt { get; init; }
|
|
public bool Sensitive { get; init; }
|
|
public string Language { get; init; }
|
|
public string Url { get; init; }
|
|
public string InReplyTo { get; init; }
|
|
public string Context { get; init; }
|
|
public string Audience { get; init; }
|
|
public string Wall { get; init; }//FEP-400e: the collection it says it was written in (`target`), a wall
|
|
public string WallOwner { get; init; }//that collection's attributedTo
|
|
public string QuoteUri { get; init; }
|
|
public string ReplyAuthorization { get; init; }//the parent author's ReplyAuthorization (approvedBy before GoToSocial 0.21)
|
|
public string QuoteAuthorization { get; init; }
|
|
public bool QuotesByConsent { get; init; }//FEP-044f `quote`, as opposed to the legacy keys that ask nobody
|
|
public bool QuoteDeleted { get; init; }
|
|
public DateTime Published { get; init; }
|
|
public DateTime? Updated { get; init; }
|
|
public IReadOnlyList<string> To { get; init; } = Array.Empty<string>();
|
|
public IReadOnlyList<string> Cc { get; init; } = Array.Empty<string>();
|
|
public IReadOnlyList<PostMention> Mentions { get; init; } = Array.Empty<PostMention>();
|
|
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
|
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
|
|
public PostSource Source { get; init; }
|
|
public PostPoll Poll { get; init; }
|
|
public InteractionRule QuotePolicy { get; init; }
|
|
public InteractionRule ReplyPolicy { get; init; }
|
|
public InteractionRule LikePolicy { get; init; }
|
|
public InteractionRule AnnouncePolicy { get; init; }
|
|
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
|
|
public string CoverURL { get; init; }
|
|
public PostLink Link { get; init; }
|
|
public VideoDetails Video { get; init; }
|
|
public AudioDetails Audio { get; init; }
|
|
public EventDetails Event { get; init; }
|
|
public PostPlace Place { get; init; }
|
|
public bool? CommentsEnabled { get; init; }//Lemmy, PieFed and Mbin: false once its moderators lock it; null when not said
|
|
}
|
|
|
|
public static class NoteParser
|
|
{
|
|
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage" };
|
|
const int MaxAttachments = 16;
|
|
const int MaxTags = 64;
|
|
|
|
public static NoteDocument Parse(JsonNode node)
|
|
{
|
|
if (node is not JsonObject note)
|
|
return default;
|
|
var type = Value(note, "type");
|
|
var id = Id(note);
|
|
if (!NoteTypes.Contains(type) || Origin.Of(id) == default)
|
|
return default;
|
|
|
|
var (contentHtml, language) = Content(note);
|
|
var summary = Value(note, "summary");
|
|
var excerpt = ObjectShapes.Text(summary, 1000)
|
|
?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Note" ? ObjectShapes.Text(Value(preview, "content"), 1000) : default);
|
|
var sensitive = note["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) && marked;
|
|
var summaryWarns = type is "Note" or "Question" || sensitive;
|
|
return new NoteDocument
|
|
{
|
|
Id = id,
|
|
Type = type,
|
|
Raw = note,
|
|
AttributedTo = Attribution(note["attributedTo"]),
|
|
ContentHtml = ContentSanitizer.Html(contentHtml),
|
|
Title = ObjectShapes.Text(Value(note, "name"), 500),
|
|
SpoilerText = summaryWarns ? Plain(summary, 500) : default,
|
|
Excerpt = summaryWarns ? default : excerpt,
|
|
Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary),
|
|
Language = language,
|
|
Url = UrlOf(note["url"]) ?? id,
|
|
InReplyTo = Id(note["inReplyTo"]),
|
|
Context = Id(note["context"]) ?? Value(note, "conversation"),
|
|
Audience = Id(note["audience"]),
|
|
Wall = note["target"] is JsonObject target && Value(target, "type") is "Collection" or "OrderedCollection" ? Id(target) : default,
|
|
WallOwner = note["target"] is JsonObject wallTarget ? Id(wallTarget["attributedTo"]) : default,
|
|
QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note),
|
|
ReplyAuthorization = Id(note["replyAuthorization"]) ?? Id(note["approvedBy"]),
|
|
QuoteAuthorization = Id(note["quoteAuthorization"]),
|
|
QuotesByConsent = note.ContainsKey("quote"),
|
|
QuoteDeleted = note["quote"] is JsonObject quoted && Value(quoted, "type") == "Tombstone",
|
|
Published = Time(Value(note, "published")) ?? DateTime.UtcNow,
|
|
Updated = Time(Value(note, "updated")),
|
|
To = List(note["to"]),
|
|
Cc = List(note["cc"]),
|
|
Mentions = Tags(note, "Mention").Select(t => new PostMention { ActorURI = Value(t, "href"), Handle = Value(t, "name") })
|
|
.Where(m => Origin.Of(m.ActorURI) != default)
|
|
.DistinctBy(m => m.ActorURI)
|
|
.Take(MaxTags)
|
|
.ToList(),
|
|
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant())
|
|
.Where(t => !string.IsNullOrEmpty(t))
|
|
.Distinct()
|
|
.Take(MaxTags)
|
|
.ToList(),
|
|
Attachments = Attachments(note),
|
|
Source = ObjectShapes.Source(note),
|
|
Poll = ObjectShapes.Poll(note),
|
|
QuotePolicy = ObjectShapes.QuotePolicy(note),
|
|
ReplyPolicy = ObjectShapes.Policy(note, "canReply"),
|
|
LikePolicy = ObjectShapes.Policy(note, "canLike"),
|
|
AnnouncePolicy = ObjectShapes.Policy(note, "canAnnounce"),
|
|
Emojis = ObjectShapes.Emojis(note["tag"]),
|
|
CoverURL = ObjectShapes.Cover(note),
|
|
Link = ObjectShapes.Link(note),
|
|
Video = ObjectShapes.Video(note),
|
|
Audio = ObjectShapes.Audio(note),
|
|
Event = ObjectShapes.Event(note),
|
|
Place = ObjectShapes.NotePlace(note),
|
|
CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue<bool>(out var enabled) ? enabled : null
|
|
};
|
|
}
|
|
|
|
static (string Html, string Language) Content(JsonObject note)
|
|
{
|
|
var content = Value(note, "content");
|
|
string language = default;
|
|
if (note["contentMap"] is JsonObject map && map.Count > 0)
|
|
{
|
|
var first = map.First();
|
|
language = first.Key;
|
|
if (string.IsNullOrEmpty(content) && first.Value is JsonValue value && value.TryGetValue<string>(out var mapped))
|
|
content = mapped;
|
|
}
|
|
if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey)
|
|
content = System.Net.WebUtility.HtmlEncode(misskey).Replace("\n", "<br>");
|
|
if (!string.IsNullOrEmpty(content) && Value(note, "mediaType")?.StartsWith("text/markdown", StringComparison.OrdinalIgnoreCase) == true)
|
|
content = Rendering.ActivityPubRenderer.Html(content);
|
|
return (content, language ?? ObjectShapes.Language(note));
|
|
}
|
|
|
|
static string Attribution(JsonNode node) => node switch
|
|
{
|
|
JsonArray array => array.OfType<JsonObject>().Where(o => Value(o, "type") is "Person" or "Service" or "Application" or "Organization").Select(Id)
|
|
.Concat(array.OfType<JsonValue>().Select(Id))
|
|
.Concat(array.OfType<JsonObject>().Where(o => Value(o, "type") == "Group").Select(Id))
|
|
.FirstOrDefault(id => id != default),
|
|
_ => Id(node)
|
|
};
|
|
|
|
static IEnumerable<JsonObject> Tags(JsonObject note, string type) =>
|
|
(note["tag"] switch
|
|
{
|
|
JsonArray array => array.OfType<JsonObject>(),
|
|
JsonObject single => new[] { single },
|
|
_ => Enumerable.Empty<JsonObject>()
|
|
}).Where(t => Value(t, "type") == type);
|
|
|
|
static List<PostMedia> Attachments(JsonObject note) =>
|
|
ObjectShapes.Objects(note["attachment"])
|
|
.Where(a => Value(a, "type") is "Document" or "Image" or "Video" or "Audio")
|
|
.Select(Attachment)
|
|
.Where(m => Origin.Of(m.RemoteURL) != default)
|
|
.Take(MaxAttachments)
|
|
.ToList();
|
|
|
|
static PostMedia Attachment(JsonObject attachment)
|
|
{
|
|
var (url, linkMediaType) = ObjectShapes.MediaLink(attachment["url"] ?? attachment["href"]);
|
|
return new PostMedia
|
|
{
|
|
RemoteURL = url,
|
|
ContentType = ObjectShapes.InferMediaType(Value(attachment, "mediaType") ?? linkMediaType, url, Value(attachment, "type")),
|
|
RemotePreviewURL = ObjectShapes.Image(attachment["icon"]),
|
|
DurationSeconds = ObjectShapes.Seconds(attachment["duration"]),
|
|
Sensitive = attachment["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) ? marked : default(bool?),
|
|
Description = Plain(Value(attachment, "name") ?? Value(attachment, "summary") ?? FirstOf(attachment["nameMap"]) ?? FirstOf(attachment["summaryMap"]), 1500),
|
|
Blurhash = Value(attachment, "blurhash"),
|
|
Width = Int(attachment["width"]),
|
|
Height = Int(attachment["height"]),
|
|
Focus = attachment["focalPoint"] is JsonArray focus && focus.Count == 2
|
|
? focus.Select(f => f is JsonValue v && v.TryGetValue<double>(out var d) ? (float)Math.Clamp(d, -1, 1) : 0f).ToArray()
|
|
: default
|
|
};
|
|
}
|
|
|
|
static string QuoteLink(JsonObject note) =>
|
|
Tags(note, "Link")
|
|
.Where(t => Value(t, "mediaType") is { } type && (type.StartsWith("application/activity+json") || type.StartsWith("application/ld+json")))
|
|
.Select(t => Value(t, "href"))
|
|
.FirstOrDefault(href => Origin.Of(href) != default);
|
|
|
|
static string FirstOf(JsonNode map) =>
|
|
map is JsonObject languages ? languages.Select(l => l.Value is JsonValue v && v.TryGetValue<string>(out var text) ? text : default).FirstOrDefault(t => t != default) : default;
|
|
|
|
static string UrlOf(JsonNode node) => node switch
|
|
{
|
|
JsonValue value when value.TryGetValue<string>(out var text) => text,
|
|
JsonObject obj => Value(obj, "href") ?? Value(obj, "id"),
|
|
JsonArray array => array.Select(item => item is JsonObject link && Value(link, "mediaType") is { } mediaType && !mediaType.StartsWith("text/html")
|
|
? default
|
|
: UrlOf(item))
|
|
.FirstOrDefault(url => url != default) ?? array.Select(UrlOf).FirstOrDefault(url => url != default),
|
|
_ => default
|
|
};
|
|
|
|
static IReadOnlyList<string> List(JsonNode node) => node switch
|
|
{
|
|
JsonArray array => array.Select(Id).Where(id => id != default).Distinct(StringComparer.Ordinal).ToList(),
|
|
JsonNode single when Id(single) is { } id => new[] { id },
|
|
_ => Array.Empty<string>()
|
|
};
|
|
|
|
static DateTime? Time(string value) =>
|
|
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
|
|
? parsed.UtcDateTime
|
|
: (DateTime?)null;
|
|
|
|
static int? Int(JsonNode node) =>
|
|
node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : (int?)null;
|
|
|
|
static string Plain(string value, int max)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return default;
|
|
value = value.Trim();
|
|
return value.Length <= max ? value : value[..max];
|
|
}
|
|
}
|
|
}
|