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:
1 parent
23dbc68fb4
commit
03824babbc
6 files changed
+155
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user