A moved account's profile says where it went

PrivaPub now fills `moved` once a move is believed; the profile shows it, linking
the new account's profile. The e2e opens the account SocialPub's moves scenario
moved and finds the new one named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 18:39:56 +02:00
1 parent 228708c328
commit f9de55ffd5
6 files changed
+51

No files matched your search

+2
View File
@@ -231,6 +231,8 @@ supplies:
link, participants and status.
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
linking the new account's profile.
A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing.
- **Groups** (`Pages/Groups.razor` at `/groups`): the persona's communities and circles (`GroupService`, `/clientapi/group`):
made (a circle unless it is a community, with an invitation code and an optional password), joined by invitation and
+2
View File
@@ -25,6 +25,8 @@ public class Account
public string LastStatusAt { get; set; }
public List<CustomEmoji> Emojis { get; set; } = [];
public List<AccountField> Fields { get; set; } = [];
/// <summary>The account it moved to (PrivaPub shows it once the new account names this one among its aliases).</summary>
public Account Moved { get; set; }
/// <summary>Whether it is listed in the directory and suggested; null when its server does not say.</summary>
public bool? Discoverable { get; set; }
/// <summary>Whether its public posts may be found by full-text search.</summary>
+8
View File
@@ -32,6 +32,14 @@
}
else
{
@if (account.Moved is { } moved)
{
<p class="flex flex-wrap items-center gap-2 p-3 rounded-xl neo-inset neo-2xs text-sm" data-moved>
<i class="ion-md-swap"></i>
<span>@Localizer["{0} has moved to", account.Name]</span>
<a class="link font-semibold" href="@($"account/{Uri.EscapeDataString(moved.Id)}")">@("@" + moved.Acct)</a>
</p>
}
<article class="flex flex-none flex-col rounded-xl neo-raised neo-xs overflow-hidden" data-account-id="@account.Id">
<div class="h-28 md:h-40 bg-base-200 bg-cover bg-center" style="@HeaderStyle"></div>
<div class="flex flex-wrap items-end gap-3 px-4 -mt-10">
+3
View File
@@ -1715,4 +1715,7 @@
<data name="Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone." xml:space="preserve">
<value>Ogni persona di questo accesso se ne va con lui, e tutto ciò che avevano di pubblico, qui e sui server che lo tengono. Non si può annullare.</value>
</data>
<data name="{0} has moved to" xml:space="preserve">
<value>{0} si è trasferito su</value>
</data>
</root>
+3
View File
@@ -1715,4 +1715,7 @@
<data name="Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone." xml:space="preserve">
<value>Every persona of this login goes with it, and everything public they had, here and on the servers that hold it. It cannot be undone.</value>
</data>
<data name="{0} has moved to" xml:space="preserve">
<value>{0} has moved to</value>
</data>
</root>
+33
View File
@@ -24,6 +24,39 @@ namespace decePubClient.E2E.Actions
return candidates.Last();
}
// the newest GoToSocial account that SocialPub's moves scenario moved (scenarios/moves.sh), as PrivaPub knows it
static async Task<(string Old, string New)> Moved()
{
var start = new System.Diagnostics.ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var arg in new[] { "exec", "pasture-mongo", "mongosh", "--quiet", "PrivaPub", "--eval",
"const a = db.ForeignAvatar.find({ActorURI: {$regex: 'gts.test.users.mvold'}, MovedToURL: {$ne: null}}).sort({_id: -1}).limit(1).toArray()[0];" +
"print(a ? a.ActorURI + ' ' + a.MovedToURL : '')" })
start.ArgumentList.Add(arg);
using var process = System.Diagnostics.Process.Start(start)!;
var output = (await process.StandardOutput.ReadToEndAsync()).Trim();
await process.WaitForExitAsync();
var parts = output.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 2 ? (parts[0], parts[1]) : default;
}
[Fact]
public async Task A_moved_accounts_profile_says_where_it_went()
{
var (old, moved) = await Moved();
Assert.SkipWhen(old is null, "no moved account: run SocialPub's tools/pasture/interop.sh moves");
var persona = Pasture.Personas().First();
var handle = old.Split("/users/")[1] + "@gts.test";
await using var session = await SignIn.As(browser, persona);
await Results.Cell("client.render.moved", "gts", handle, async () =>
{
await session.Page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("@" + handle)}");
var banner = session.Page.Locator("[data-moved]");
await banner.WaitForAsync(new() { Timeout = 30000 });
await Assertions.Expect(banner).ToContainTextAsync("@" + moved.Split("/users/")[1] + "@gts.test");
await session.AssertGuards();
}, session.Page);
}
[Theory]
[MemberData(nameof(Origins))]
public async Task A_remote_account_found_by_its_handle_is_followed_and_unfollowed_from_its_profile(string platform)