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.
|
||||
- **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.
|
||||
|
||||
@@ -293,6 +293,14 @@
|
||||
}
|
||||
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>
|
||||
<i aria-hidden="true" class="ion-md-create text-base"></i>
|
||||
<span>@Localizer["Edit"]</span>
|
||||
@@ -701,6 +709,8 @@
|
||||
|
||||
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;
|
||||
|
||||
void ToggleChoice(Poll poll, int index)
|
||||
|
||||
+20
-2
@@ -147,7 +147,17 @@
|
||||
</DTabs>
|
||||
|
||||
<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" />
|
||||
}
|
||||
@@ -182,6 +192,8 @@
|
||||
Account account;
|
||||
Relationship relationship;
|
||||
List<FeedPost> posts = [];
|
||||
List<FeedPost> pinned = [];
|
||||
HashSet<string> 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)
|
||||
{
|
||||
|
||||
@@ -1718,4 +1718,13 @@
|
||||
<data name="{0} has moved to" xml:space="preserve">
|
||||
<value>{0} si è trasferito su</value>
|
||||
</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>
|
||||
@@ -1718,4 +1718,13 @@
|
||||
<data name="{0} has moved to" xml:space="preserve">
|
||||
<value>{0} has moved to</value>
|
||||
</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>
|
||||
@@ -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>
|
||||
public async Task<List<Account>> FollowRequests(CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -21,7 +21,9 @@ namespace decePubClient.Services
|
||||
Reblog,
|
||||
Unreblog,
|
||||
Bookmark,
|
||||
Unbookmark
|
||||
Unbookmark,
|
||||
Pin,
|
||||
Unpin
|
||||
}
|
||||
|
||||
/// <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