Featured tags per persona (/api/v1/featured_tags, suggestions, tags/:name/feature, accounts' featured tags, max 10),
listed at /tattoos as Hashtags and sent as Add/Remove{Hashtag} to followers on the featured collection, as Mastodon
sends them; remote accounts' tags received and read from featuredTags. Endorsements (pin/endorse) and private notes
(PersonaNote) kept per persona and never federated; the relationship's notifying, languages, endorsed and note, with
follow's notify (status notifications) and languages (home filter). Reading preferences set on /clientapi. Suggestions
v1/v2 with dismissals and staff picks, never the persona or a sibling. conversation events on the direct stream.
translate, annual reports and the admin API answer. New collections backed up and removed with their persona.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
114 lines
5.4 KiB
C#
114 lines
5.4 KiB
C#
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Domain.Relationships;
|
|
using PrivaPub.Domain.Social;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
|
|
|
namespace PrivaPub.Federation.Inbox.Handlers
|
|
{
|
|
// An account moving to another (Mastodon's Move{object: itself, target: the new account}), believed as Mastodon
|
|
// believes it: the moving account sends it about itself, and the new account, read again from its own server, names
|
|
// it among its alsoKnownAs. The old account then shows where it went (`moved`), and, as Mastodon does it (owner
|
|
// decision 2026-10-05), the personas following it follow the new one instead, in the same lists, and those that
|
|
// muted or blocked it mute or block the new one too.
|
|
public class MoveHandler : IActivityHandler
|
|
{
|
|
readonly IRemoteActorService _remoteActors;
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IFollowService _follows;
|
|
readonly IRelationshipService _relationships;
|
|
|
|
public MoveHandler(IRemoteActorService remoteActors, DbEntities dbEntities, ILocalActorService localActors, IFollowService follows,
|
|
IRelationshipService relationships)
|
|
{
|
|
_remoteActors = remoteActors;
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_follows = follows;
|
|
_relationships = relationships;
|
|
}
|
|
|
|
public string Type => "Move";
|
|
|
|
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
|
{
|
|
var target = Id(activity["target"]);
|
|
if (Id(activity["object"]) != actor.ActorURI || string.IsNullOrEmpty(target) || target == actor.ActorURI)
|
|
{
|
|
Arrival.Drop("not-self");
|
|
return;
|
|
}
|
|
var moved = await _remoteActors.GetActor(target, refresh: true, token);
|
|
if (moved == default)
|
|
{
|
|
Arrival.Drop("target-unavailable");
|
|
return;
|
|
}
|
|
if (!moved.AlsoKnownAs.Contains(actor.ActorURI))
|
|
{
|
|
Arrival.Drop("not-aliased");
|
|
return;
|
|
}
|
|
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID).Modify(a => a.MovedToURL, target).ExecuteAsync(token);
|
|
await MoveFollows(actor, moved, token);
|
|
await CarryOver(actor, moved, token);
|
|
Arrival.Accept("moved");
|
|
}
|
|
|
|
async Task MoveFollows(ForeignAvatar old, ForeignAvatar moved, CancellationToken token)
|
|
{
|
|
foreach (var following in await _dbEntities.Followings.Match(f => f.TargetActorURI == old.ActorURI).ExecuteAsync(token))
|
|
{
|
|
var persona = await _localActors.FindById(LocalActorKind.Person, following.AvatarId, token);
|
|
if (persona == default)
|
|
continue;
|
|
var lists = await DB.Default.Find<PersonaListMember>().Match(m => m.AvatarId == persona.Id && m.AccountId == old.ID).ExecuteAsync(token);
|
|
var endorsed = await DB.Default.Find<Endorsement>().Match(e => e.AvatarId == persona.Id && e.TargetActorURI == old.ActorURI).ExecuteAnyAsync(token);
|
|
var followed = await _follows.FollowAs(persona, moved.ActorURI, following.ShowReblogs, token, following.Notify, following.Languages ?? new List<string>());
|
|
await _follows.UnfollowAs(persona, old.ActorURI, token);
|
|
if (followed == default)
|
|
continue;
|
|
// the persona's endorsement and private note go with the follow, as Mastodon carries them over (R8)
|
|
if (endorsed)
|
|
await DB.Default.Update<Endorsement>().Match(e => e.AvatarId == persona.Id && e.TargetAccountId == moved.ID)
|
|
.Modify(e => e.TargetActorURI, moved.ActorURI).Modify(b => b.SetOnInsert(e => e.CreatedAt, DateTime.UtcNow))
|
|
.Option(o => o.IsUpsert = true).ExecuteAsync(token);
|
|
if (await DB.Default.Find<PersonaNote>().Match(n => n.AvatarId == persona.Id && n.TargetAccountId == old.ID).ExecuteFirstAsync(token) is { } note
|
|
&& !await DB.Default.Find<PersonaNote>().Match(n => n.AvatarId == persona.Id && n.TargetAccountId == moved.ID).ExecuteAnyAsync(token))
|
|
await DB.Default.SaveAsync(new PersonaNote { AvatarId = persona.Id, TargetAccountId = moved.ID, Text = note.Text }, token);
|
|
foreach (var member in lists)
|
|
try
|
|
{
|
|
await DB.Default.SaveAsync(new PersonaListMember { ListId = member.ListId, AvatarId = persona.Id, AccountId = moved.ID }, token);
|
|
}
|
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
async Task CarryOver(ForeignAvatar old, ForeignAvatar moved, CancellationToken token)
|
|
{
|
|
foreach (var mute in await DB.Default.Find<Mute>().Match(m => m.TargetActorURI == old.ActorURI).ExecuteAsync(token))
|
|
if (await _localActors.FindById(LocalActorKind.Person, mute.AvatarId, token) is { } persona
|
|
&& !await DB.Default.Find<Mute>().Match(m => m.AvatarId == persona.Id && m.TargetActorURI == moved.ActorURI).ExecuteAnyAsync(token))
|
|
await _relationships.Mute(persona, moved.ActorURI, moved.ID, mute.HideNotifications,
|
|
mute.ExpiresAt is { } expires ? expires - DateTime.UtcNow : default(TimeSpan?), token);
|
|
foreach (var block in await DB.Default.Find<Block>().Match(b => b.TargetActorURI == old.ActorURI).ExecuteAsync(token))
|
|
if (await _localActors.FindById(LocalActorKind.Person, block.AvatarId, token) is { } persona
|
|
&& !await DB.Default.Find<Block>().Match(b => b.AvatarId == persona.Id && b.TargetActorURI == moved.ActorURI).ExecuteAnyAsync(token))
|
|
await _relationships.Block(persona, moved.ActorURI, moved.ID, token);
|
|
}
|
|
}
|
|
}
|