A post on a wall says whose, and the persona takes posts off its own

PrivaPub hosts what a persona's followers write on its wall (Smithereen's, FEP-400e) and shows posts written on walls
elsewhere to their owners' followers. A card names the wall (privapub.wall, data-wall, linking the profile), and one
written on the persona's own wall is taken off from the card's menu, which PrivaPub tells as a Remove and Smithereen
applies as a deletion. WallTests checks it against Smithereen.

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-06 08:38:49 +02:00
1 parent 23dbc68fb4
commit 03824babbc
6 files changed
+155

No files matched your search

+3
View File
@@ -239,6 +239,9 @@ supplies:
persona pins and unpins its own public and unlisted posts from the card's menu. persona pins and unpins its own public and unlisted posts from the card's menu.
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one), 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. linking the new account's profile.
- **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking
that profile); one written on the persona's own wall is taken off from the card's menu (`DELETE` on the status, which
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing. 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`): - **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 made (a circle unless it is a community, with an invitation code and an optional password), joined by invitation and
+36
View File
@@ -118,6 +118,16 @@
</span> </span>
</header> </header>
@if (status.Privapub?.Wall is { } wall)
{
<p class="inline-flex items-center gap-2 min-w-0 text-xs opacity-80" data-wall="@wall.Acct">
<i class="ion-md-clipboard" aria-hidden="true"></i>
<a class="truncate" href="@($"account/{Uri.EscapeDataString(wall.Id)}")" title="@wall.Acct">
@Localizer["On the wall of {0}", wall.Name]
</a>
</p>
}
@if (Warnings.Count > 0 && !showFiltered) @if (Warnings.Count > 0 && !showFiltered)
{ {
<div class="flex flex-wrap items-center gap-2 text-sm rounded-lg neo-inset neo-2xs px-3 py-2" data-filtered> <div class="flex flex-wrap items-center gap-2 text-sm rounded-lg neo-inset neo-2xs px-3 py-2" data-filtered>
@@ -298,6 +308,13 @@
<span>@Localizer["Quote"]</span> <span>@Localizer["Quote"]</span>
</DButton> </DButton>
} }
@if (IsOnOwnWall(status))
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="TakeOffWall" data-take-off-wall>
<i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
<span>@Localizer["Take off my wall"]</span>
</DButton>
}
@if (!IsOwn) @if (!IsOwn)
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
@@ -828,6 +845,25 @@
await OnDeleted.InvokeAsync(Post); await OnDeleted.InvokeAsync(Post);
} }
// someone's post on the persona's wall: its owner deletes it there (PrivaPub tells the wall's readers, and Smithereen
// deletes the post)
bool IsOnOwnWall(Status status) => !IsOwn && status.Privapub?.Wall?.Id is { } owner && owner == CascadingState.PersonaId;
async Task TakeOffWall()
{
isActing = true;
var result = await StatusService.Delete(Status.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["Taken off your wall."]);
await OnDeleted.InvokeAsync(Post);
}
async Task AccountAction(string action) async Task AccountAction(string action)
{ {
isActing = true; isActing = true;
+2
View File
@@ -21,6 +21,8 @@ public class PrivaPubStatus
/// <summary>A remote post's own place (Pixelfed's location), shown only.</summary> /// <summary>A remote post's own place (Pixelfed's location), shown only.</summary>
public PrivaPubPlace Place { get; set; } public PrivaPubPlace Place { get; set; }
public PrivaPubVotes Votes { get; set; } public PrivaPubVotes Votes { get; set; }
/// <summary>Whose wall it was written on (Smithereen's walls, FEP-400e), when not its author's own.</summary>
public Account Wall { get; set; }
} }
public class PrivaPubSource public class PrivaPubSource
+9
View File
@@ -1730,4 +1730,13 @@
<data name="Read article" xml:space="preserve"> <data name="Read article" xml:space="preserve">
<value>Leggi l'articolo</value> <value>Leggi l'articolo</value>
</data> </data>
<data name="On the wall of {0}" xml:space="preserve">
<value>Sulla bacheca di {0}</value>
</data>
<data name="Take off my wall" xml:space="preserve">
<value>Togli dalla mia bacheca</value>
</data>
<data name="Taken off your wall." xml:space="preserve">
<value>Tolto dalla tua bacheca.</value>
</data>
</root> </root>
+9
View File
@@ -1730,4 +1730,13 @@
<data name="Read article" xml:space="preserve"> <data name="Read article" xml:space="preserve">
<value>Read article</value> <value>Read article</value>
</data> </data>
<data name="On the wall of {0}" xml:space="preserve">
<value>On the wall of {0}</value>
</data>
<data name="Take off my wall" xml:space="preserve">
<value>Take off my wall</value>
</data>
<data name="Taken off your wall." xml:space="preserve">
<value>Taken off your wall.</value>
</data>
</root> </root>
+96
View File
@@ -0,0 +1,96 @@
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// A Smithereen account that follows the persona writes on its wall (FEP-400e): the card says so, and the persona takes
// it off from the card's menu, which deletes it on Smithereen too. SocialPub's tools/pasture/scenarios/smithereen.sh
// checks the rest of the wire.
public sealed class WallTests(Browser browser)
{
// Smithereen's VKontakte-like API as smuser (the token SocialPub's peers/smithereen.sh keeps)
static async Task<JsonNode> Smithereen(string token, string method, params (string Name, string Value)[] args)
{
using var http = Pasture.Http();
using var request = new HttpRequestMessage(HttpMethod.Post, $"https://smithereen.test/api/method/{method}")
{
Content = new FormUrlEncodedContent(args.Select(a => KeyValuePair.Create(a.Name, a.Value)).Append(KeyValuePair.Create("v", "1.0")))
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await http.SendAsync(request);
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task A_post_on_the_personas_wall_says_so_and_the_persona_takes_it_off()
{
var tokenFile = Path.Combine(Pasture.Root, ".state", "smithereen", "smuser.token");
Assert.SkipUnless(File.Exists(tokenFile), "no Smithereen: run tools/pasture/run.sh add smithereen");
var token = File.ReadAllText(tokenFile).Trim();
// (one whose follows need no approval)
var persona = default(Pasture.Persona);
var me = default(JsonNode);
foreach (var candidate in Pasture.Personas())
{
me = await PersonaApi.Call(candidate, "/api/v1/accounts/verify_credentials");
if (me?["locked"]?.GetValue<bool>() == false)
{
persona = candidate;
break;
}
}
Assert.SkipWhen(persona is null, "every persona of the town is locked");
var actorUri = me["url"]!.GetValue<string>().Replace("/@", "/peasants/");
// (its profile told again, so Smithereen reads its wall; smuser follows it, as its followers may write there)
await PersonaApi.Call(persona, "/api/v1/accounts/update_credentials", HttpMethod.Patch, new JsonObject());
var personaOnSm = (await Smithereen(token, "utils.loadRemoteObject", ("q", actorUri)))?["response"]?["id"]?.ToString();
Assert.SkipWhen(string.IsNullOrEmpty(personaOnSm), "Smithereen cannot resolve the persona");
await Smithereen(token, "friends.add", ("user_id", personaOnSm));
try
{
var smuser = (await PersonaApi.Call(persona, "/api/v2/search?q=smuser%40smithereen.test&resolve=true&type=accounts"))!["accounts"]![0]!["id"]!.GetValue<string>();
Assert.True(await Pasture.Eventually(async () =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={smuser}"))![0]!["followed_by"]!.GetValue<bool>(), 45), "smuser never followed the persona");
var text = $"a word on the wall {Guid.NewGuid():N}"[..32];
var written = (await Smithereen(token, "wall.post", ("owner_id", personaOnSm), ("message", text)))?["response"]?.ToString();
Assert.False(string.IsNullOrEmpty(written), "Smithereen refused smuser's post on the persona's wall");
JsonNode status = null;
Assert.True(await Pasture.Eventually(async () =>
(status = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()
.FirstOrDefault(s => s!["content"]!.GetValue<string>().Contains(text))) is not null, 60), "the post on the wall never reached the persona");
Assert.Equal(me["id"]!.GetValue<string>(), status!["privapub"]?["wall"]?["id"]?.GetValue<string>());
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
await Results.Cell("client.action.wall", "smithereen", persona.UserName, async () =>
{
await page.GotoAsync($"{Pasture.Site}/");
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
await page.GotoAsync($"{Pasture.Site}/");
var card = page.Locator($"article[data-uri='{status["uri"]!.GetValue<string>()}']").First;
await card.WaitForAsync(new() { Timeout = 30000 });
await Assertions.Expect(card.Locator("[data-wall]")).ToBeVisibleAsync();
await Assertions.Expect(card.Locator("[data-wall]")).ToContainTextAsync(me["display_name"]!.GetValue<string>() is { Length: > 0 } name ? name : me["username"]!.GetValue<string>());
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
await card.Locator("button[data-take-off-wall]").ClickAsync();
Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{status["id"]!.GetValue<string>()}") is null, 15),
"the post is still on PrivaPub");
Assert.True(await Pasture.Eventually(async () =>
(await Smithereen(token, "wall.getById", ("posts", written)))?["response"] is not JsonArray { Count: > 0 }, 30), "the post is still on Smithereen");
await session.AssertGuards();
}, page);
}
finally
{
// (the town as it was: smuser no longer follows the persona)
await Smithereen(token, "friends.delete", ("user_id", personaOnSm));
}
}
}
}