An account elsewhere that pins or unpins one of its posts (Add or Remove on its `featured`) now shows those pins on its profile here (`pinned=true`), in its order and its public posts only; a community's announce of a moderator's Add does the same for the community. The `featured` collection itself is read with the account's counts, at most once a day, so pins made before PrivaPub ever saw an account show too. Any other target (Smithereen's wall, a community's moderators) is dropped. A persona's pin and unpin go to the post's audience as Add and Remove on /trophies, as Mastodon sends them. Checked live against Mastodon (scenarios/pins.sh, 8 checks). The town's checker learnt three peer rules from the village: Misskey and Sharkey keep a forwarded reply only with its author's LD signature (only Mastodon signs), they count no renote by a bot, and Mastodon never sees a Lemmy vote on a post in a community. The village of 2026-10-05 checks clean, 2454 of 2454. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
157 lines
5.8 KiB
C#
157 lines
5.8 KiB
C#
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Inbox;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Infrastructure.Http;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Federation.Actors
|
|
{
|
|
public interface IFeaturedPosts
|
|
{
|
|
Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token);
|
|
Task Sync(ForeignAvatar owner, CancellationToken token);
|
|
}
|
|
|
|
// What an account elsewhere features (pins): its `featured` collection, read with its counts at most once a day, and
|
|
// the Add and Remove it sends when it pins or unpins. An account features only its own posts; a community (Lemmy's,
|
|
// inside its announce) features posts made in it. Shown first on its profile here, as Mastodon shows them.
|
|
public class FeaturedPosts : IFeaturedPosts
|
|
{
|
|
public const int MaxFeatured = 20;
|
|
|
|
readonly DbEntities _dbEntities;
|
|
readonly IRemoteActorService _remoteActors;
|
|
readonly IRemotePosts _remotePosts;
|
|
|
|
public FeaturedPosts(DbEntities dbEntities, IRemoteActorService remoteActors, IRemotePosts remotePosts)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_remoteActors = remoteActors;
|
|
_remotePosts = remotePosts;
|
|
}
|
|
|
|
public async Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(owner.FeaturedURL) || Id(activity["target"]) != owner.FeaturedURL)
|
|
{
|
|
Arrival.Drop("unknown-target");
|
|
return;
|
|
}
|
|
var objectUri = Id(activity["object"]);
|
|
if (objectUri == default)
|
|
{
|
|
Arrival.Drop("unparseable");
|
|
return;
|
|
}
|
|
if (!add)
|
|
{
|
|
var removed = await DB.Default.DeleteAsync<RemoteFeatured>(f => f.ActorURI == owner.ActorURI && f.ObjectURI == objectUri);
|
|
if (removed is { IsAcknowledged: true, DeletedCount: > 0 })
|
|
Arrival.Accept("unfeatured");
|
|
else
|
|
Arrival.Drop("unknown-object");
|
|
return;
|
|
}
|
|
var post = await Featurable(owner, objectUri, token);
|
|
if (post == default)
|
|
{
|
|
Arrival.Drop("unknown-object");
|
|
return;
|
|
}
|
|
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
|
|
var first = await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == owner.ActorURI).Sort(f => f.Position, Order.Ascending).ExecuteFirstAsync(token);
|
|
try
|
|
{
|
|
await DB.Default.SaveAsync(new RemoteFeatured
|
|
{
|
|
ActorURI = owner.ActorURI,
|
|
ObjectURI = objectUri,
|
|
PostId = post.ID,
|
|
Position = (first?.Position ?? 0) - 1
|
|
}, token);
|
|
Arrival.Accept("featured");
|
|
}
|
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
Arrival.Drop("duplicate");
|
|
}
|
|
}
|
|
|
|
public async Task Sync(ForeignAvatar owner, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(owner.FeaturedURL) || !Origin.Same(owner.FeaturedURL, owner.ActorURI))
|
|
return;
|
|
var uris = await Items(owner, token);
|
|
if (uris == default)
|
|
return;
|
|
var featured = new List<RemoteFeatured>();
|
|
foreach (var uri in uris.Distinct(StringComparer.Ordinal).Take(MaxFeatured))
|
|
if (await Featurable(owner, uri, token) is { } post)
|
|
featured.Add(new RemoteFeatured { ActorURI = owner.ActorURI, ObjectURI = uri, PostId = post.ID, Position = featured.Count });
|
|
await DB.Default.DeleteAsync<RemoteFeatured>(f => f.ActorURI == owner.ActorURI);
|
|
if (featured.Count > 0)
|
|
await DB.Default.SaveAsync(featured, token);
|
|
}
|
|
|
|
// the post, held or fetched from its own server, when the owner may feature it
|
|
async Task<PostEntity> Featurable(ForeignAvatar owner, string objectUri, CancellationToken token)
|
|
{
|
|
var isGroup = owner.AvatarType is AvatarType.Group;
|
|
if (!isGroup && !Origin.Same(objectUri, owner.ActorURI))
|
|
return default;
|
|
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
|
|
?? await _remotePosts.StoreContext(objectUri, 0, token);
|
|
return post switch
|
|
{
|
|
null => default,
|
|
{ ReblogOfPostId: not null } => default,
|
|
_ when isGroup => post.AudienceURI == owner.ActorURI ? post : default,
|
|
_ => post.ActorURI == owner.ActorURI ? post : default
|
|
};
|
|
}
|
|
|
|
// the collection's items, in its order, from its first page when it pages: links or embedded objects alike
|
|
async Task<List<string>> Items(ForeignAvatar owner, CancellationToken token)
|
|
{
|
|
using var scope = HttpScope.For("collection");
|
|
using var collection = await _remoteActors.FetchObject(owner.FeaturedURL, token);
|
|
if (collection == default || collection.Root.ValueKind != JsonValueKind.Object)
|
|
return default;
|
|
var items = ItemsOf(collection.Root);
|
|
if (items.Count > 0 || !collection.Root.TryGetProperty("first", out var first))
|
|
return items;
|
|
if (first.ValueKind == JsonValueKind.Object)
|
|
return ItemsOf(first);
|
|
var page = first.ValueKind == JsonValueKind.String ? first.GetString() : default;
|
|
if (page == default || !Origin.Same(page, owner.ActorURI))
|
|
return items;
|
|
using var fetched = await _remoteActors.FetchObject(page, token);
|
|
return fetched == default || fetched.Root.ValueKind != JsonValueKind.Object ? items : ItemsOf(fetched.Root);
|
|
}
|
|
|
|
static List<string> ItemsOf(JsonElement collection)
|
|
{
|
|
var items = new List<string>();
|
|
foreach (var name in new[] { "orderedItems", "items" })
|
|
if (collection.TryGetProperty(name, out var list) && list.ValueKind == JsonValueKind.Array)
|
|
foreach (var item in list.EnumerateArray())
|
|
if (item.ValueKind == JsonValueKind.String)
|
|
items.Add(item.GetString());
|
|
else if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String)
|
|
items.Add(id.GetString());
|
|
return items;
|
|
}
|
|
}
|
|
}
|