- @foreach (var post in posts)
+ @if (tab is ProfileTab.Posts && pinned.Count > 0)
+ {
+
+ @Localizer["Pinned"]
+ @foreach (var post in pinned)
+ {
+
+ }
+
+ }
+ @foreach (var post in posts.Where(p => tab is not ProfileTab.Posts || !pinnedKeys.Contains(p.Key)))
{
}
@@ -182,6 +192,8 @@
Account account;
Relationship relationship;
List
posts = [];
+ List pinned = [];
+ HashSet pinnedKeys = [];
ProfileTab tab = ProfileTab.Posts;
string maxId;
string loadedId;
@@ -234,6 +246,8 @@
}
relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id);
await LoadLists();
+ pinned = await AccountService.Pinned(account.Id);
+ pinnedKeys = [.. pinned.Select(p => p.Key)];
posts = [];
maxId = null;
IsLoading = false;
@@ -329,7 +343,11 @@
});
}
- void Remove(FeedPost post) => posts.RemoveAll(p => p.Key == post.Key);
+ void Remove(FeedPost post)
+ {
+ posts.RemoveAll(p => p.Key == post.Key);
+ pinned.RemoveAll(p => p.Key == post.Key);
+ }
async Task OpenTag(string tagName)
{
diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx
index e44343e..8499cd4 100644
--- a/Resources/AllStrings.it.resx
+++ b/Resources/AllStrings.it.resx
@@ -1718,4 +1718,13 @@
{0} si รจ trasferito su
+
+ In evidenza
+
+
+ Fissa nel profilo
+
+
+ Togli dal profilo
+
diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx
index d6830d0..8ac028a 100644
--- a/Resources/AllStrings.resx
+++ b/Resources/AllStrings.resx
@@ -1718,4 +1718,13 @@
{0} has moved to
+
+ Pinned
+
+
+ Pin on profile
+
+
+ Unpin from profile
+
diff --git a/Services/AccountService.cs b/Services/AccountService.cs
index b113e8a..d31fff2 100644
--- a/Services/AccountService.cs
+++ b/Services/AccountService.cs
@@ -144,6 +144,17 @@ namespace decePubClient.Services
};
}
+ /// The posts the account pins, in its order: what it features, for an account elsewhere.
+ public async Task> Pinned(string accountId, CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountStatuses(accountId), ["pinned=true"], cancellationToken: cancellationToken);
+ if (response.IsSuccessStatusCode)
+ return [.. (await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []).Select(FeedPost.Of)];
+
+ await LogFailure(response, nameof(Pinned), cancellationToken);
+ return [];
+ }
+
/// The accounts waiting for the persona to accept their follow.
public async Task> FollowRequests(CancellationToken cancellationToken = default)
{
diff --git a/Services/StatusService.cs b/Services/StatusService.cs
index 57b4812..f994788 100644
--- a/Services/StatusService.cs
+++ b/Services/StatusService.cs
@@ -21,7 +21,9 @@ namespace decePubClient.Services
Reblog,
Unreblog,
Bookmark,
- Unbookmark
+ Unbookmark,
+ Pin,
+ Unpin
}
///
diff --git a/tests/e2e/Actions/PinTests.cs b/tests/e2e/Actions/PinTests.cs
new file mode 100644
index 0000000..d999f7f
--- /dev/null
+++ b/tests/e2e/Actions/PinTests.cs
@@ -0,0 +1,55 @@
+using System.Text.Json.Nodes;
+
+using decePubClient.E2E.Support;
+
+using Microsoft.Playwright;
+
+namespace decePubClient.E2E.Actions
+{
+ // A persona pins its own post from the card, finds it above its posts on its profile, and unpins it. PrivaPub tells
+ // the persona's followers with Add and Remove; SocialPub's tools/pasture/scenarios/pins.sh checks that side.
+ public sealed class PinTests(Browser browser)
+ {
+ [Fact]
+ public async Task A_persona_pins_its_post_from_the_card_and_its_profile_shows_it_first()
+ {
+ var persona = Pasture.Personas().First();
+ var text = $"pin me {Guid.NewGuid():N}"[..20];
+ var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
+ var id = post["id"]!.GetValue();
+ var me = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["id"]!.GetValue();
+ async Task Pinned() =>
+ (await PersonaApi.Call(persona, $"/api/v1/accounts/{me}/statuses?pinned=true"))!.AsArray().Any(s => s!["id"]!.GetValue() == id);
+ await using var session = await SignIn.As(browser, persona);
+ var page = session.Page;
+ try
+ {
+ await Results.Cell("client.action.pin", "privapub", persona.UserName, async () =>
+ {
+ await page.GotoAsync($"{Pasture.Site}/account/{me}");
+ var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue()}']").First;
+ await card.WaitForAsync(new() { Timeout = 30000 });
+
+ await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
+ await card.Locator("button[data-pin]").ClickAsync();
+ Assert.True(await Pasture.Eventually(Pinned, 15), "the pin never reached PrivaPub");
+
+ await page.ReloadAsync();
+ var pinnedCard = page.Locator($"[data-pinned] article[data-uri='{post["uri"]!.GetValue()}']");
+ await pinnedCard.WaitForAsync(new() { Timeout = 30000 });
+ Assert.Equal(1, await page.Locator($"article[data-uri='{post["uri"]!.GetValue()}']").CountAsync());
+
+ await pinnedCard.Locator("footer [title='Other'] > [role=button]").ClickAsync();
+ await Assertions.Expect(pinnedCard.Locator("button[data-pin]")).ToContainTextAsync("Unpin", new() { Timeout = 5000 });
+ await pinnedCard.Locator("button[data-pin]").ClickAsync();
+ Assert.True(await Pasture.Eventually(async () => !await Pinned(), 15), "the unpin never reached PrivaPub");
+ await session.AssertGuards();
+ }, page);
+ }
+ finally
+ {
+ await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
+ }
+ }
+ }
+}