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:
1 parent
a5178a67b1
commit
da98addf76
8 files changed
+119
-3
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ namespace decePubClient.Services
|
|||||||
Reblog,
|
Reblog,
|
||||||
Unreblog,
|
Unreblog,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Unbookmark
|
Unbookmark,
|
||||||
|
Pin,
|
||||||
|
Unpin
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user