Pins across servers, both ways
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
This commit is contained in:
1 parent
5f150b7a59
commit
1c7d1ece9c
23 files changed
+510
-19
No files matched your search
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user