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)); + } + } + } +}