From da98addf7646bb1b729024e8e05e57b585811df5 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 23:04:31 +0200 Subject: [PATCH] Pinned posts on profiles, and pinning from the card A profile shows the posts the account pins above its posts (pinned=true: for an account elsewhere, what it features, which PrivaPub now keeps); the persona pins and unpins its own public and unlisted posts from the card's menu, and PrivaPub tells its followers. tests/e2e/Actions/PinTests.cs pins from the card and finds the post first on the profile; the whole e2e suite passes against the reseeded village (122, 4 skipped). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 2 ++ Components/Content.razor | 10 +++++++ Pages/Profile.razor | 22 ++++++++++++-- Resources/AllStrings.it.resx | 9 ++++++ Resources/AllStrings.resx | 9 ++++++ Services/AccountService.cs | 11 +++++++ Services/StatusService.cs | 4 ++- tests/e2e/Actions/PinTests.cs | 55 +++++++++++++++++++++++++++++++++++ 8 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/Actions/PinTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 84b5d96..9b31f5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,6 +235,8 @@ supplies: link, participants and status. - **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's author and a notification's account open the profile in the app; "Open original" is the only way to the remote site. + A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the + 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. A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing. diff --git a/Components/Content.razor b/Components/Content.razor index 48624fc..d6201c6 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -293,6 +293,14 @@ } else { + @if (CanPin(status)) + { + + + @(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"]) + + } @Localizer["Edit"] @@ -701,6 +709,8 @@ HashSet pollChoices = []; + // a profile shows the persona's own public and unlisted posts it pins, as Mastodon allows + bool CanPin(Status status) => !Compact && !Post.Located && status.Reblog is null && status.Visibility is ("public" or "unlisted"); bool CanVote(Poll poll) => !poll.Expired && poll.Voted != true && !IsOwn && !Compact && !Post.Located; void ToggleChoice(Poll poll, int index) diff --git a/Pages/Profile.razor b/Pages/Profile.razor index 4ed0ed7..869d090 100644 --- a/Pages/Profile.razor +++ b/Pages/Profile.razor @@ -147,7 +147,17 @@
- @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); + } + } + } +}