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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 23:04:31 +02:00
1 parent a5178a67b1
commit da98addf76
8 files changed
+119 -3

No files matched your search

+2
View File
@@ -235,6 +235,8 @@ supplies:
link, participants and status. link, participants and status.
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's - **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. 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), 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.
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.
+10
View File
@@ -293,6 +293,14 @@
} }
else else
{ {
@if (CanPin(status))
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Pinned ? StatusAction.Unpin : StatusAction.Pin)" data-pin>
<i aria-hidden="true" class="ion-md-pin text-base"></i>
<span>@(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"])</span>
</DButton>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit>
<i aria-hidden="true" class="ion-md-create text-base"></i> <i aria-hidden="true" class="ion-md-create text-base"></i>
<span>@Localizer["Edit"]</span> <span>@Localizer["Edit"]</span>
@@ -701,6 +709,8 @@
HashSet<int> pollChoices = []; HashSet<int> 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; bool CanVote(Poll poll) => !poll.Expired && poll.Voted != true && !IsOwn && !Compact && !Post.Located;
void ToggleChoice(Poll poll, int index) void ToggleChoice(Poll poll, int index)
+20 -2
View File
@@ -147,7 +147,17 @@
</DTabs> </DTabs>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
@foreach (var post in posts) @if (tab is ProfileTab.Posts && pinned.Count > 0)
{
<div class="flex flex-col gap-4" data-pinned>
<span class="badge badge-ghost gap-1 self-start"><i aria-hidden="true" class="ion-md-pin"></i>@Localizer["Pinned"]</span>
@foreach (var post in pinned)
{
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" />
}
</div>
}
@foreach (var post in posts.Where(p => tab is not ProfileTab.Posts || !pinnedKeys.Contains(p.Key)))
{ {
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" /> <Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" />
} }
@@ -182,6 +192,8 @@
Account account; Account account;
Relationship relationship; Relationship relationship;
List<FeedPost> posts = []; List<FeedPost> posts = [];
List<FeedPost> pinned = [];
HashSet<string> pinnedKeys = [];
ProfileTab tab = ProfileTab.Posts; ProfileTab tab = ProfileTab.Posts;
string maxId; string maxId;
string loadedId; string loadedId;
@@ -234,6 +246,8 @@
} }
relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id); relationship = account.Id == CascadingState.PersonaId ? null : await AccountService.Relationship(account.Id);
await LoadLists(); await LoadLists();
pinned = await AccountService.Pinned(account.Id);
pinnedKeys = [.. pinned.Select(p => p.Key)];
posts = []; posts = [];
maxId = null; maxId = null;
IsLoading = false; 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) async Task OpenTag(string tagName)
{ {
+9
View File
@@ -1718,4 +1718,13 @@
<data name="{0} has moved to" xml:space="preserve"> <data name="{0} has moved to" xml:space="preserve">
<value>{0} si è trasferito su</value> <value>{0} si è trasferito su</value>
</data> </data>
<data name="Pinned" xml:space="preserve">
<value>In evidenza</value>
</data>
<data name="Pin on profile" xml:space="preserve">
<value>Fissa nel profilo</value>
</data>
<data name="Unpin from profile" xml:space="preserve">
<value>Togli dal profilo</value>
</data>
</root> </root>
+9
View File
@@ -1718,4 +1718,13 @@
<data name="{0} has moved to" xml:space="preserve"> <data name="{0} has moved to" xml:space="preserve">
<value>{0} has moved to</value> <value>{0} has moved to</value>
</data> </data>
<data name="Pinned" xml:space="preserve">
<value>Pinned</value>
</data>
<data name="Pin on profile" xml:space="preserve">
<value>Pin on profile</value>
</data>
<data name="Unpin from profile" xml:space="preserve">
<value>Unpin from profile</value>
</data>
</root> </root>
+11
View File
@@ -144,6 +144,17 @@ namespace decePubClient.Services
}; };
} }
/// <summary>The posts the account pins, in its order: what it features, for an account elsewhere.</summary>
public async Task<List<FeedPost>> 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<List<Status>>(cancellationToken) ?? []).Select(FeedPost.Of)];
await LogFailure(response, nameof(Pinned), cancellationToken);
return [];
}
/// <summary>The accounts waiting for the persona to accept their follow.</summary> /// <summary>The accounts waiting for the persona to accept their follow.</summary>
public async Task<List<Account>> FollowRequests(CancellationToken cancellationToken = default) public async Task<List<Account>> FollowRequests(CancellationToken cancellationToken = default)
{ {
+3 -1
View File
@@ -21,7 +21,9 @@ namespace decePubClient.Services
Reblog, Reblog,
Unreblog, Unreblog,
Bookmark, Bookmark,
Unbookmark Unbookmark,
Pin,
Unpin
} }
/// <summary> /// <summary>
+55
View File
@@ -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<string>();
var me = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["id"]!.GetValue<string>();
async Task<bool> Pinned() =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/{me}/statuses?pinned=true"))!.AsArray().Any(s => s!["id"]!.GetValue<string>() == 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<string>()}']").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<string>()}']");
await pinnedCard.WaitForAsync(new() { Timeout = 30000 });
Assert.Equal(1, await page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").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);
}
}
}
}