diff --git a/CLAUDE.md b/CLAUDE.md
index 6bae6db..5b208c2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -239,6 +239,9 @@ supplies:
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),
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.
- **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
diff --git a/Components/Content.razor b/Components/Content.razor
index da2e69b..30c8b3a 100644
--- a/Components/Content.razor
+++ b/Components/Content.razor
@@ -118,6 +118,16 @@
+ @if (status.Privapub?.Wall is { } wall)
+ {
+
+
+
+ @Localizer["On the wall of {0}", wall.Name]
+
+
+ }
+
@if (Warnings.Count > 0 && !showFiltered)
{
@@ -298,6 +308,13 @@
@Localizer["Quote"]
}
+ @if (IsOnOwnWall(status))
+ {
+
+
+ @Localizer["Take off my wall"]
+
+ }
@if (!IsOwn)
{
@@ -828,6 +845,25 @@
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)
{
isActing = true;
diff --git a/Models/Mastodon/PrivaPubStatus.cs b/Models/Mastodon/PrivaPubStatus.cs
index 0ebe1fa..b179bd6 100644
--- a/Models/Mastodon/PrivaPubStatus.cs
+++ b/Models/Mastodon/PrivaPubStatus.cs
@@ -21,6 +21,8 @@ public class PrivaPubStatus
/// A remote post's own place (Pixelfed's location), shown only.
public PrivaPubPlace Place { get; set; }
public PrivaPubVotes Votes { get; set; }
+ /// Whose wall it was written on (Smithereen's walls, FEP-400e), when not its author's own.
+ public Account Wall { get; set; }
}
public class PrivaPubSource
diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx
index 934be8a..0e2ec3f 100644
--- a/Resources/AllStrings.it.resx
+++ b/Resources/AllStrings.it.resx
@@ -1730,4 +1730,13 @@
Leggi l'articolo
+
+ Sulla bacheca di {0}
+
+
+ Togli dalla mia bacheca
+
+
+ Tolto dalla tua bacheca.
+
diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx
index f6730ac..30445f0 100644
--- a/Resources/AllStrings.resx
+++ b/Resources/AllStrings.resx
@@ -1730,4 +1730,13 @@
Read article
+
+ On the wall of {0}
+
+
+ Take off my wall
+
+
+ Taken off your wall.
+
diff --git a/tests/e2e/Actions/WallTests.cs b/tests/e2e/Actions/WallTests.cs
new file mode 100644
index 0000000..d362a32
--- /dev/null
+++ b/tests/e2e/Actions/WallTests.cs
@@ -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 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() == false)
+ {
+ persona = candidate;
+ break;
+ }
+ }
+ Assert.SkipWhen(persona is null, "every persona of the town is locked");
+ var actorUri = me["url"]!.GetValue().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();
+ Assert.True(await Pasture.Eventually(async () =>
+ (await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={smuser}"))![0]!["followed_by"]!.GetValue(), 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().Contains(text))) is not null, 60), "the post on the wall never reached the persona");
+ Assert.Equal(me["id"]!.GetValue(), status!["privapub"]?["wall"]?["id"]?.GetValue());
+
+ 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()}']").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() is { Length: > 0 } name ? name : me["username"]!.GetValue());
+ 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()}") 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));
+ }
+ }
+ }
+}