diff --git a/CLAUDE.md b/CLAUDE.md index 25597e0..439f9ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,7 +288,7 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. (`NotificationPermission`). 7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt, cover, the author's source, link, video, audio and event details, a remote post's own place (Pixelfed's `location`, - never federated again), and up/down votes. Every media URL in it goes + never federated again), up/down votes, and its community's flairs (`ObjectShapes.Flairs`, both dialects). Every media URL in it goes through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following one is the reader's choice. diff --git a/PrivaPub.Tests/Federation/FlairTests.cs b/PrivaPub.Tests/Federation/FlairTests.cs new file mode 100644 index 0000000..3a62756 --- /dev/null +++ b/PrivaPub.Tests/Federation/FlairTests.cs @@ -0,0 +1,68 @@ +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Objects; + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Federation +{ + public class FlairTests + { + [Fact] + public void A_lemmy_posts_flair_is_described_by_its_community() + { + using var group = JsonDocument.Parse(""" + { + "id": "https://lemmy.example/c/tenforward", "type": "Group", "preferredUsername": "tenforward", "inbox": "https://lemmy.example/c/tenforward/inbox", + "attributedTo": "https://lemmy.example/c/tenforward/moderators", + "tag": [{ "type": "CommunityPostTag", "id": "https://lemmy.example/c/tenforward/tag/news", "preferredUsername": "news", + "name": "News", "content": "What happened", "color": "color03" }] + } + """); + var community = ActorDocument.Parse(group.RootElement); + var page = NoteParser.Parse(JsonNode.Parse(""" + { + "id": "https://lemmy.example/post/1", "type": "Page", "name": "Big news", "attributedTo": "https://lemmy.example/u/bo", + "tag": [{ "type": "CommunityPostTag", "id": "https://lemmy.example/c/tenforward/tag/news", "preferredUsername": "news" }, + { "type": "Mention", "href": "https://lemmy.example/c/tenforward", "name": "@tenforward@lemmy.example" }] + } + """)); + + Assert.Equal("https://lemmy.example/c/tenforward/moderators", community.Moderators); + var flair = Assert.Single(ObjectShapes.Described(page.Flairs, community.Flairs)); + Assert.Equal("News", flair.Name); + Assert.Equal("news", flair.Slug); + Assert.Equal("color03", flair.Color); + Assert.Equal("What happened", flair.Description); + } + + [Fact] + public void Reads_piefeds_flairs_in_both_dialects_and_its_older_community_list() + { + var post = NoteParser.Parse(JsonNode.Parse(""" + { + "id": "https://piefed.example/post/9", "type": "Page", "name": "A question", "attributedTo": "https://piefed.example/u/pf", + "tag": [{ "type": "lemmy:CommunityTag", "id": "https://piefed.example/c/ask/tag/3", "display_name": "Solved", + "text_color": "#ffffff", "background_color": "#2e7d32", "blur_images": false }, + { "type": "Hashtag", "href": "https://piefed.example/tag/q", "name": "#q" }] + } + """)); + using var group = JsonDocument.Parse(""" + { + "id": "https://piefed.example/c/ask", "type": "Group", "preferredUsername": "ask", "inbox": "https://piefed.example/c/ask/inbox", + "lemmy:tagsForPosts": [{ "type": "lemmy:CommunityTag", "id": "https://piefed.example/c/ask/tag/4", "display_name": "Spoiler", + "text_color": "#000", "background_color": "red; background: url(x)", "blur_images": true }] + } + """); + + var solved = Assert.Single(post.Flairs); + Assert.Equal("Solved", solved.Name); + Assert.Equal("#2e7d32", solved.BackgroundColor); + Assert.Equal("q", Assert.Single(post.Tags)); + var spoiler = Assert.Single(ActorDocument.Parse(group.RootElement).Flairs); + Assert.True(spoiler.BlurImages); + Assert.Equal("#000", spoiler.TextColor); + Assert.Null(spoiler.BackgroundColor); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index aaefb2f..d0bc5ae 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -79,6 +79,7 @@ public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only public Account Wall { get; set; }//whose wall it was written on (FEP-400e), when not its author's public PrivaPubVotes Votes { get; set; } = new(); + public List Flairs { get; set; } = new(); } public class PrivaPubSource @@ -163,6 +164,18 @@ public string Url { get; set; } } + public class PrivaPubFlair + { + public string Id { get; set; } + public string Name { get; set; } + public string Slug { get; set; } + public string Description { get; set; } + public string Color { get; set; } + public string TextColor { get; set; } + public string BackgroundColor { get; set; } + public bool BlurImages { get; set; } + } + public class PrivaPubVotes { public int Up { get; set; } diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index a56029c..30b5129 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -543,7 +543,12 @@ namespace PrivaPub.Api.Mastodon.Mappers { Name = post.Place.Name, Latitude = post.Place.Latitude, Longitude = post.Place.Longitude, Country = post.Place.Country }, - Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount } + Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount }, + Flairs = post.Flairs?.Select(f => new PrivaPubFlair + { + Id = f.Id, Name = f.Name, Slug = f.Slug, Description = f.Description, Color = f.Color, TextColor = f.TextColor, + BackgroundColor = f.BackgroundColor, BlurImages = f.BlurImages + }).ToList() ?? new() }; PrivaPubVariant Variant(MediaVariant variant) => new() diff --git a/PrivaPub/Federation/Actors/ActorDocument.cs b/PrivaPub/Federation/Actors/ActorDocument.cs index fab1475..b07a1c4 100644 --- a/PrivaPub/Federation/Actors/ActorDocument.cs +++ b/PrivaPub/Federation/Actors/ActorDocument.cs @@ -37,6 +37,7 @@ namespace PrivaPub.Federation.Actors public string HeaderDescription { get; init; } public string MovedTo { get; init; } public string Moderators { get; init; } + public List Flairs { get; init; } = new(); public List AlsoKnownAs { get; init; } = new();//the accounts it says it also is (a Move's target names the moved one) public DateTime? Published { get; init; } public List Emojis { get; init; } = new(); @@ -93,6 +94,8 @@ namespace PrivaPub.Federation.Actors IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default, HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default, MovedTo = RemoteActorService.Text(root, "movedTo"), + Flairs = type != "Group" ? new() : ObjectShapes.Flairs(root.TryGetProperty("tag", out var labels) ? JsonNode.Parse(labels.GetRawText()) : default, + root.TryGetProperty("lemmy:tagsForPosts", out var older) ? JsonNode.Parse(older.GetRawText()) : default), Moderators = type == "Group" ? RemoteActorService.Text(root, "attributedTo") ?? RemoteActorService.Text(root, "moderators") : default, AlsoKnownAs = root.TryGetProperty("alsoKnownAs", out var aliases) ? Uris(aliases) : new(), Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published) diff --git a/PrivaPub/Federation/Actors/RemoteActorService.cs b/PrivaPub/Federation/Actors/RemoteActorService.cs index 8a7a87e..0355527 100644 --- a/PrivaPub/Federation/Actors/RemoteActorService.cs +++ b/PrivaPub/Federation/Actors/RemoteActorService.cs @@ -235,6 +235,7 @@ namespace PrivaPub.Federation.Actors .Modify(a => a.IsMemorial, actor.Memorial) .Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo) .Modify(a => a.ModeratorsURL, Origin.Same(actor.Moderators, actor.Id) ? actor.Moderators : default) + .Modify(a => a.Flairs, actor.Flairs) .Modify(a => a.AlsoKnownAs, actor.AlsoKnownAs) .Modify(a => a.Published, actor.Published) .Modify(a => a.Emojis, actor.Emojis) diff --git a/PrivaPub/Federation/Inbox/RemoteEdits.cs b/PrivaPub/Federation/Inbox/RemoteEdits.cs index d0b3b9c..b520a9f 100644 --- a/PrivaPub/Federation/Inbox/RemoteEdits.cs +++ b/PrivaPub/Federation/Inbox/RemoteEdits.cs @@ -38,6 +38,7 @@ namespace PrivaPub.Federation.Inbox post.Audio = note.Audio ?? post.Audio; post.Event = note.Event ?? post.Event; post.Place = note.Place; + post.Flairs = await RemotePosts.Flairs(note, post.AudienceURI, token); if (note.CommentsEnabled is { } enabled) post.LockedAt = enabled ? null : post.LockedAt ?? DateTime.UtcNow; if (!IsEdit(note, post)) diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index 2c77a13..353a3bc 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -137,10 +137,20 @@ namespace PrivaPub.Federation.Inbox EditedAt = note.Updated, Revisions = note.FormerRepresentations.ToList() }; + post.Flairs = await Flairs(note, post.AudienceURI, token); await Episode(post, note, author, token); return post; } + // a post's flairs, filled in from its community's list + public static async Task> Flairs(NoteDocument note, string communityUri, CancellationToken token) + { + if (note.Flairs.Count == 0) + return default; + var community = communityUri == default ? default : await DB.Default.Find().Match(f => f.ActorURI == communityUri).ExecuteFirstAsync(token); + return ObjectShapes.Described(note.Flairs, community?.Flairs); + } + static readonly System.Text.RegularExpressions.Regex Href = new(@"href=""([^""]+)""", System.Text.RegularExpressions.RegexOptions.Compiled); // a post that is only a link to a podcast episode of its own author (Castopod announces each episode so): the diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs index f0fb2e5..d4e47ea 100644 --- a/PrivaPub/Federation/Objects/NoteParser.cs +++ b/PrivaPub/Federation/Objects/NoteParser.cs @@ -40,6 +40,7 @@ namespace PrivaPub.Federation.Objects public IReadOnlyList Attachments { get; init; } = Array.Empty(); public PostSource Source { get; init; } public PostPoll Poll { get; init; } + public List Flairs { get; init; } = new(); public InteractionRule QuotePolicy { get; init; } public InteractionRule ReplyPolicy { get; init; } public InteractionRule LikePolicy { get; init; } @@ -119,6 +120,7 @@ namespace PrivaPub.Federation.Objects Attachments = Attachments(note), Source = ObjectShapes.Source(note), Poll = ObjectShapes.Poll(note), + Flairs = ObjectShapes.Flairs(note["tag"]), QuotePolicy = ObjectShapes.QuotePolicy(note), ReplyPolicy = ObjectShapes.Policy(note, "canReply"), LikePolicy = ObjectShapes.Policy(note, "canLike"), diff --git a/PrivaPub/Federation/Objects/ObjectShapes.cs b/PrivaPub/Federation/Objects/ObjectShapes.cs index a6e7289..c710d91 100644 --- a/PrivaPub/Federation/Objects/ObjectShapes.cs +++ b/PrivaPub/Federation/Objects/ObjectShapes.cs @@ -98,6 +98,48 @@ namespace PrivaPub.Federation.Objects .Take(MaxEmojis) .ToList(); + const int MaxFlairs = 20; + + // a post's flairs, or a community's (its `tag`, and PieFed's older `lemmy:tagsForPosts`), in both dialects + public static List Flairs(params JsonNode[] lists) => + lists.SelectMany(Objects) + .Where(t => Value(t, "type") is "CommunityPostTag" or "lemmy:CommunityTag" or "CommunityTag") + .Select(t => new PostFlair + { + Id = Id(t) is { } id && Origin.Of(id) != default ? id : default, + Name = Text(Value(t, "name") ?? Value(t, "display_name") ?? Value(t, "preferredUsername"), 100), + Slug = Text(Value(t, "preferredUsername") ?? Value(t, "display_name"), 100), + Description = Text(Value(t, "content"), 500), + Color = Value(t, "color") is { } slot && FlairSlot().IsMatch(slot) ? slot : default, + TextColor = Hex(Value(t, "textColor") ?? Value(t, "text_color")), + BackgroundColor = Hex(Value(t, "backgroundColor") ?? Value(t, "background_color")), + BlurImages = Bool(t, "blurImages") ?? Bool(t, "blur_images") ?? false + }) + .Where(f => f.Name != default) + .DistinctBy(f => f.Id ?? f.Slug) + .Take(MaxFlairs) + .ToList(); + + // a post's flairs as its community describes them: Lemmy names only the id and slug on the post + public static List Described(List onPost, List ofCommunity) + { + if (onPost == default || onPost.Count == 0) + return default; + foreach (var flair in onPost) + if (ofCommunity?.FirstOrDefault(c => c.Id != default && c.Id == flair.Id || c.Slug == flair.Slug) is { } known) + { + flair.Name = flair.Name == flair.Slug ? known.Name ?? flair.Name : flair.Name; + flair.Description ??= known.Description; + flair.Color ??= known.Color; + flair.TextColor ??= known.TextColor; + flair.BackgroundColor ??= known.BackgroundColor; + flair.BlurImages |= known.BlurImages; + } + return onPost; + } + + static string Hex(string value) => value != default && HexColour().IsMatch(value) ? value : default; + public static InteractionRule QuotePolicy(JsonObject note) => Policy(note, "canQuote"); // one rule of a post's interactionPolicy (GoToSocial, FEP-044f): who may, automatically or once asked; the old names @@ -399,6 +441,12 @@ namespace PrivaPub.Federation.Objects [GeneratedRegex(@"^[\w+\-.@]+$")] private static partial Regex Shortcode(); + [GeneratedRegex(@"^color\d{2}$")] + private static partial Regex FlairSlot(); + + [GeneratedRegex(@"^#[0-9a-fA-F]{3,8}$")] + private static partial Regex HexColour(); + [GeneratedRegex("<[^>]*>")] private static partial Regex Tags(); diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index 9c7e04c..ac4f201 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -46,6 +46,8 @@ namespace PrivaPub.Models.Post public PostPlace Place { get; set; } [BsonIgnoreIfNull] public PostPoll Poll { get; set; } + [BsonIgnoreIfNull] + public List Flairs { get; set; } public List Location { get; set; } = new(); public float RangeKm { get; set; } = 5.0f; [BsonIgnoreIfNull] diff --git a/PrivaPub/Models/Post/PostDetails.cs b/PrivaPub/Models/Post/PostDetails.cs index 7045f5d..0086ff2 100644 --- a/PrivaPub/Models/Post/PostDetails.cs +++ b/PrivaPub/Models/Post/PostDetails.cs @@ -38,6 +38,19 @@ public List Manual { get; set; } = new(); } + // a community's label on a post (Lemmy 1.0's CommunityPostTag, PieFed's lemmy:CommunityTag): one of the community's own + public class PostFlair + { + public string Id { get; set; } + public string Name { get; set; } + public string Slug { get; set; } + public string Description { get; set; } + public string Color { get; set; }//Lemmy's colour slot, color01 to color10 + public string TextColor { get; set; }//PieFed's, as #rrggbb + public string BackgroundColor { get; set; } + public bool BlurImages { get; set; } + } + public class PostSource { public string Content { get; set; } diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index 50ceeba..1774230 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -77,6 +77,7 @@ namespace PrivaPub.Models.User public string InboxURL { get; set; } public string OutboxURL { get; set; } public string FeaturedURL { get; set; }//featured: the posts it pins + public List Flairs { get; set; } = new();//a community's labels for its posts public string ModeratorsURL { get; set; }//a community's attributedTo (Lemmy, PieFed, Mbin): the collection of its moderators public List AssertionKeys { get; set; } = new();//its Ed25519 keys (FEP-521a), which prove what it sends (FEP-8b32) public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 996dcbd..7a40dad 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -691,7 +691,9 @@ it, raw where it doesn't. ban of a persona refuses its posts there and shows as `blocked_by`), featured; `Warn` and `Resolve` (**done 2026-10-07**: a warning becomes the persona's `moderation_warning` notification, believed from the community's server or its moderators collection, kept as `ForeignAvatar.ModeratorsURL`; a resolution is kept on our report); - - votes in and out; link posts; flairs; `Feed` actors; community polls; post `Move`; + - votes in and out; link posts; flairs (**done 2026-10-07**: Lemmy 1.0's `CommunityPostTag` and PieFed's + `lemmy:CommunityTag`, on posts and communities, described from the community's list, as `privapub.flairs`); `Feed` + actors; community polls; post `Move`; - the outbound shape Lemmy requires; - communities we host announce to the author's own instance too: **done 2026-10-06** (its server's shared inbox, whether anyone there follows the community or not: Lemmy keeps its user's post pending until it is announced back);