Personas a remote post is addressed to see it, named or not
A remote post decided who here may see it by its Mention tags alone. A followers-only post addressed in to or cc to a persona without naming it (Akkoma's to[], or a GoToSocial edit that took the @name out while the post stayed addressed to the persona) was hidden from that persona. Such personas are now kept as silent mentions, as Mastodon does: they see the post and it reaches their home, and no list shows them as mentioned. An edit never narrows who a post was for. The GoToSocial note that showed it is the first captured fixture (Fixtures/gotosocial), and parses with its lone tag, to and cc. 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
b9286c2c6e
commit
08acdc0503
7 files changed
+62
-10
No files matched your search
@@ -263,6 +263,28 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal(alice.Id, mention.AccountId);
|
Assert.Equal(alice.Id, mention.AccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GoToSocial kept a persona in cc after an edit took the @name out, and Akkoma addresses with to[] alone: the persona it
|
||||||
|
// is addressed to sees a followers-only post as a silent mention, which no list shows as a mention (found by the town)
|
||||||
|
[Fact]
|
||||||
|
public async Task A_followers_only_post_addressed_to_a_persona_without_naming_it_is_a_silent_mention()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var alice = await LocalAvatar("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var create = PublicCreate(bob, default, alice.Uri);
|
||||||
|
create["object"]!["to"] = bob.Id + "/followers";
|
||||||
|
create["object"]!["cc"] = alice.Uri;
|
||||||
|
|
||||||
|
await Deliver(bob, "/human-centipede", create);
|
||||||
|
|
||||||
|
var post = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
||||||
|
Assert.Equal(PostVisibility.FollowersOnly, post.Visibility);
|
||||||
|
var mention = Assert.Single(post.Mentions);
|
||||||
|
Assert.True(mention.Silent);
|
||||||
|
Assert.Equal(alice.Id, mention.AccountId);
|
||||||
|
Assert.True(await PrivaPub.Domain.Privacy.VisibilityPolicy.CanSee(post, alice.Id, token));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched()
|
public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Null(note.Updated);
|
Assert.Null(note.Updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GoToSocial writes a lone tag, and lone to and cc, as plain values instead of arrays (captured by the town)
|
||||||
|
[Fact]
|
||||||
|
public void Reads_a_gotosocial_reply_whose_only_tag_and_audience_are_single_values()
|
||||||
|
{
|
||||||
|
var note = NoteParser.Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "gotosocial",
|
||||||
|
"followers-only-reply-one-tag.json"))));
|
||||||
|
|
||||||
|
var mention = Assert.Single(note.Mentions);
|
||||||
|
Assert.Equal("https://privapub.test/peasants/flor_pp98", mention.ActorURI);
|
||||||
|
Assert.Equal(["https://gts.test/users/ines_gt59/followers"], note.To);
|
||||||
|
Assert.Equal(["https://privapub.test/peasants/flor_pp98"], note.Cc);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Reads_a_lemmy_page_with_a_title()
|
public void Reads_a_lemmy_page_with_a_title()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"attributedTo":"https://gts.test/users/ines_gt59","cc":"https://privapub.test/peasants/flor_pp98","content":"\u003Cp\u003E\u003Cspan class=\u0022h-card\u0022\u003E\u003Ca href=\u0022https://privapub.test/@flor_pp98\u0022 class=\u0022u-url mention\u0022 rel=\u0022nofollow noreferrer noopener\u0022 target=\u0022_blank\u0022\u003E@\u003Cspan\u003Eflor_pp98\u003C/span\u003E\u003C/a\u003E\u003C/span\u003E bicycle the window bicycle tomorrow festival (p111)\u003C/p\u003E","contentMap":{"en":"\u003Cp\u003E\u003Cspan class=\u0022h-card\u0022\u003E\u003Ca href=\u0022https://privapub.test/@flor_pp98\u0022 class=\u0022u-url mention\u0022 rel=\u0022nofollow noreferrer noopener\u0022 target=\u0022_blank\u0022\u003E@\u003Cspan\u003Eflor_pp98\u003C/span\u003E\u003C/a\u003E\u003C/span\u003E bicycle the window bicycle tomorrow festival (p111)\u003C/p\u003E"},"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY","inReplyTo":"https://privapub.test/peasants/flor_pp98/scribbles/6ac2beaf092dc0af8ce6035e","interactionPolicy":{"canAnnounce":{"automaticApproval":["https://gts.test/users/ines_gt59"]},"canLike":{"automaticApproval":["https://gts.test/users/ines_gt59","https://gts.test/users/ines_gt59/followers","https://privapub.test/peasants/flor_pp98"]},"canQuote":{"automaticApproval":["https://gts.test/users/ines_gt59"]},"canReply":{"automaticApproval":["https://gts.test/users/ines_gt59","https://gts.test/users/ines_gt59/followers","https://privapub.test/peasants/flor_pp98"]}},"published":"2026-10-04T21:02:31Z","replies":{"first":{"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies?page=true","next":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies?page=true\u0026only_other_accounts=false","partOf":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies","type":"CollectionPage"},"id":"https://gts.test/users/ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY/replies","type":"Collection"},"tag":{"href":"https://privapub.test/peasants/flor_pp98","name":"@flor_pp98@privapub.test","type":"Mention"},"to":"https://gts.test/users/ines_gt59/followers","type":"Note","url":"https://gts.test/@ines_gt59/statuses/01M44BKDVTWFAMAQ3YSBM4QETY"}
|
||||||
@@ -232,7 +232,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
Privapub = Extension(post),
|
Privapub = Extension(post),
|
||||||
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
||||||
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
|
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
|
||||||
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
||||||
};
|
};
|
||||||
return status;
|
return status;
|
||||||
|
|||||||
@@ -53,12 +53,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
if (post.Revisions.Count > MaxRevisions)
|
if (post.Revisions.Count > MaxRevisions)
|
||||||
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
|
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
|
||||||
|
|
||||||
var mentions = new List<PostMention>();
|
//an edit never narrows who a post was for: what it was addressed to before still holds
|
||||||
foreach (var mention in note.Mentions)
|
var mentions = await RemotePosts.Mentions(note, post.To.Concat(post.Cc).Concat(note.To).Concat(note.Cc), localActors, token);
|
||||||
{
|
|
||||||
var local = await localActors.FindByUri(mention.ActorURI, token);
|
|
||||||
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
|
|
||||||
}
|
|
||||||
|
|
||||||
post.Title = note.Title;
|
post.Title = note.Title;
|
||||||
post.SpoilerText = note.SpoilerText;
|
post.SpoilerText = note.SpoilerText;
|
||||||
|
|||||||
@@ -55,15 +55,33 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
_queue = queue;
|
_queue = queue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PostEntity> Build(NoteDocument note, ForeignAvatar author, PostVisibility visibility, IReadOnlyList<string> to,
|
// The accounts a remote post names (its Mention tags), plus our personas it is addressed to without naming them: Mastodon
|
||||||
IReadOnlyList<string> cc, PostEntity parent, CancellationToken token)
|
// keeps those as silent mentions, and Akkoma's to[], or an edit that dropped the @name, address someone that way. A
|
||||||
|
// silent mention lets the persona see the post and puts it in its home, and is never shown as a mention.
|
||||||
|
public static async Task<List<PostMention>> Mentions(NoteDocument note, IEnumerable<string> addressed, ILocalActorService localActors,
|
||||||
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
var mentions = new List<PostMention>();
|
var mentions = new List<PostMention>();
|
||||||
foreach (var mention in note.Mentions)
|
foreach (var mention in note.Mentions)
|
||||||
{
|
{
|
||||||
var local = await _localActors.FindByUri(mention.ActorURI, token);
|
var local = await localActors.FindByUri(mention.ActorURI, token);
|
||||||
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
|
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
|
||||||
}
|
}
|
||||||
|
foreach (var uri in addressed.Where(a => !string.IsNullOrEmpty(a) && !Addressing.IsPublic(a)).Distinct(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (mentions.Any(m => m.ActorURI == uri))
|
||||||
|
continue;
|
||||||
|
var local = await localActors.FindByUri(uri, token);
|
||||||
|
if (local is { Kind: LocalActorKind.Person })
|
||||||
|
mentions.Add(new PostMention { ActorURI = uri, Handle = "@" + local.Handle, IsLocal = true, AccountId = local.Id, Silent = true });
|
||||||
|
}
|
||||||
|
return mentions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PostEntity> Build(NoteDocument note, ForeignAvatar author, PostVisibility visibility, IReadOnlyList<string> to,
|
||||||
|
IReadOnlyList<string> cc, PostEntity parent, CancellationToken token)
|
||||||
|
{
|
||||||
|
var mentions = await Mentions(note, to.Concat(cc), _localActors, token);
|
||||||
|
|
||||||
return new PostEntity
|
return new PostEntity
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ namespace PrivaPub.Models.Post
|
|||||||
public string AccountId { get; set; }
|
public string AccountId { get; set; }
|
||||||
public string Handle { get; set; }
|
public string Handle { get; set; }
|
||||||
public bool IsLocal { get; set; }
|
public bool IsLocal { get; set; }
|
||||||
|
//addressed in to or cc without a Mention tag (Mastodon's silent mention): may see the post, but the post does not name it
|
||||||
|
public bool Silent { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PostRevision
|
public class PostRevision
|
||||||
|
|||||||
Reference in new issue
Block a user