- A post's flairs are read in both dialects: Lemmy 1.0's CommunityPostTag (id, slug, name, description, colour slot) and PieFed's lemmy:CommunityTag (display name, text and background colours, whether to blur images). Colours are kept only as a colour slot or a hex value. - A community's own list comes from its `tag` and PieFed's older `lemmy:tagsForPosts`, and fills in a post that names only a flair's id and slug, as Lemmy's posts do. Edits and refreshes keep them current. - Clients get them as `privapub.flairs`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
587997dd67
commit
d4b1788a4f
14 files changed
+172
-3
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PrivaPubFlair> 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; }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<PostFlair> Flairs { get; init; } = new();
|
||||
public List<string> 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<CustomEmoji> 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<List<PostFlair>> Flairs(NoteDocument note, string communityUri, CancellationToken token)
|
||||
{
|
||||
if (note.Flairs.Count == 0)
|
||||
return default;
|
||||
var community = communityUri == default ? default : await DB.Default.Find<ForeignAvatar>().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
|
||||
|
||||
@@ -40,6 +40,7 @@ namespace PrivaPub.Federation.Objects
|
||||
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
|
||||
public PostSource Source { get; init; }
|
||||
public PostPoll Poll { get; init; }
|
||||
public List<PostFlair> 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"),
|
||||
|
||||
@@ -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<PostFlair> 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<PostFlair> Described(List<PostFlair> onPost, List<PostFlair> 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();
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ namespace PrivaPub.Models.Post
|
||||
public PostPlace Place { get; set; }
|
||||
[BsonIgnoreIfNull]
|
||||
public PostPoll Poll { get; set; }
|
||||
[BsonIgnoreIfNull]
|
||||
public List<PostFlair> Flairs { get; set; }
|
||||
public List<float> Location { get; set; } = new();
|
||||
public float RangeKm { get; set; } = 5.0f;
|
||||
[BsonIgnoreIfNull]
|
||||
|
||||
@@ -38,6 +38,19 @@
|
||||
public List<string> 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; }
|
||||
|
||||
@@ -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<PrivaPub.Models.Post.PostFlair> 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<AssertionKey> 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
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user