Articles fold in a feed, with their cover, excerpt and a Read button

An Article or a Page with its title, or a titled post longer than a few paragraphs (WriteFreely sends its posts as Notes
with a name), shows its cover, its excerpt and a Read button in a feed, and its whole text on demand; the post opened
in its thread shows whole. A feed of Ghost, WordPress and WriteFreely long reads stays a feed. tests/e2e ArticleTests
checks it with a WriteFreely blog; the render matrix still passes (70).

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-06 01:00:57 +02:00
1 parent 2f29872745
commit 5a7e1804a9
5 files changed
+112 -1

No files matched your search

+3
View File
@@ -252,6 +252,9 @@ supplies:
lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks, lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks,
conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
its media (blur). its media (blur).
- **Articles** fold in a feed (`Content.IsFoldedArticle`): an Article or a Page with its title, or a titled post longer
than a few paragraphs (WriteFreely sends Notes with a name), shows its cover, its excerpt and a Read button; the
post opened in its thread shows whole.
- **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a - **The composer** (`Components/Composer.razor`) writes a post, a reply (`InReplyTo`), a quote (`Quoting`, offered on a
card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and card when the server says the persona may quote it) or an edit of the persona's own post (`Editing`: its words and
warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media. warning from `/source`, a PUT that keeps its media, visibility and poll); a post can hold a poll instead of media.
+40 -1
View File
@@ -133,7 +133,25 @@
<p class="text-sm md:text-base font-bold break-words">@title</p> <p class="text-sm md:text-base font-bold break-words">@title</p>
} }
@if (status.SpoilerText is { Length: > 0 } spoiler) @if (IsFoldedArticle(status))
{
@* an article (WordPress, Ghost, WriteFreely, Lemmy's titled posts) shows its cover and excerpt in a feed, and
its whole text on demand: a feed of long reads stays a feed *@
<div class="flex flex-col gap-2" data-article>
@if (status.Privapub.Cover is { Length: > 0 } cover)
{
<img alt="" loading="lazy" class="w-full max-h-56 object-cover rounded-lg" src="@cover" />
}
@if (ArticleExcerpt(status) is { Length: > 0 } excerpt)
{
<p class="text-sm opacity-80 break-words line-clamp-4">@excerpt</p>
}
<DButton Size="DSize.Sm" class="self-start" OnClick="() => isArticleOpen = true" data-article-open>
<i aria-hidden="true" class="ion-md-book"></i><span>@Localizer["Read article"]</span>
</DButton>
</div>
}
else if (status.SpoilerText is { Length: > 0 } spoiler)
{ {
<DCollapse @bind-IsOpen="isSpoilerOpen" Icon="DCollapseIcon.Arrow" class="rounded-lg neo-xs" HeaderClass="text-sm py-2 min-h-0" <DCollapse @bind-IsOpen="isSpoilerOpen" Icon="DCollapseIcon.Arrow" class="rounded-lg neo-xs" HeaderClass="text-sm py-2 min-h-0"
ContentClass="text-sm"> ContentClass="text-sm">
@@ -391,6 +409,7 @@
bool isOptionsOpen; bool isOptionsOpen;
bool isSpoilerOpen; bool isSpoilerOpen;
bool showFiltered; bool showFiltered;
bool isArticleOpen;
bool isReadingProvenance; bool isReadingProvenance;
bool isReadingServer; bool isReadingServer;
readonly HashSet<string> revealedMedia = []; readonly HashSet<string> revealedMedia = [];
@@ -709,6 +728,26 @@
HashSet<int> pollChoices = []; HashSet<int> pollChoices = [];
// folded until asked for, in a feed: an article with its title (an Article or a Page, or a long titled Note, as
// WriteFreely sends its posts), not the post opened in its thread, nor one behind a content warning (the warning
// folds it already)
bool IsFoldedArticle(Status status) => !isArticleOpen && !Highlighted && status.SpoilerText is not { Length: > 0 }
&& status.Privapub is { Title.Length: > 0 } extra
&& (extra.ObjectType is "Article" or "Page" || (status.Content?.Length ?? 0) > LongRead);
// how much markup a titled post holds before a feed folds it
const int LongRead = 600;
// the article's own summary, or the start of its text
static string ArticleExcerpt(Status status)
{
if (status.Privapub?.Excerpt is { Length: > 0 } excerpt)
return excerpt;
var text = System.Text.RegularExpressions.Regex.Replace(status.Content ?? "", "<[^>]+>", " ");
text = System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ")).Trim();
return text.Length <= 280 ? text : text[..280].TrimEnd() + "…";
}
// a profile shows the persona's own public and unlisted posts it pins, as Mastodon allows // 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 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;
+3
View File
@@ -1727,4 +1727,7 @@
<data name="Unpin from profile" xml:space="preserve"> <data name="Unpin from profile" xml:space="preserve">
<value>Togli dal profilo</value> <value>Togli dal profilo</value>
</data> </data>
<data name="Read article" xml:space="preserve">
<value>Leggi l'articolo</value>
</data>
</root> </root>
+3
View File
@@ -1727,4 +1727,7 @@
<data name="Unpin from profile" xml:space="preserve"> <data name="Unpin from profile" xml:space="preserve">
<value>Unpin from profile</value> <value>Unpin from profile</value>
</data> </data>
<data name="Read article" xml:space="preserve">
<value>Read article</value>
</data>
</root> </root>
+63
View File
@@ -0,0 +1,63 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// An article in a feed shows its title, its excerpt and a Read button, and its whole text once asked: a WriteFreely
// blog the persona follows publishes one (SocialPub's peers/writefreely.sh keeps the blog's token).
public sealed class ArticleTests(Browser browser)
{
[Fact]
public async Task An_article_is_folded_in_the_feed_and_read_on_demand()
{
var tokenFile = Path.Combine(Pasture.Root, ".state", "writefreely", "wfuser.token");
Assert.SkipUnless(File.Exists(tokenFile), "no WriteFreely blog: run tools/pasture/run.sh add writefreely");
var persona = Pasture.Personas().First();
var blog = (await PersonaApi.Call(persona, "/api/v2/search?q=wfuser%40writefreely.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
Assert.SkipWhen(blog is null, "PrivaPub cannot resolve the WriteFreely blog");
var blogId = blog!["id"]!.GetValue<string>();
await PersonaApi.Call(persona, $"/api/v1/accounts/{blogId}/follow", HttpMethod.Post);
Assert.True(await Pasture.Eventually(async () =>
(await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={blogId}"))![0]!["following"]!.GetValue<bool>(), 45), "the blog never accepted");
var title = $"An article to fold {Guid.NewGuid():N}"[..30];
var ending = $"the last words {Guid.NewGuid():N}"[..24];
var body = string.Join(" ", Enumerable.Repeat("A long paragraph that a feed should not show whole.", 12)) + " " + ending;
using var http = Pasture.Http();
using var publish = new HttpRequestMessage(HttpMethod.Post, "https://writefreely.test/api/collections/wfuser/posts")
{
Content = new StringContent(new JsonObject { ["title"] = title, ["body"] = body }.ToJsonString(), Encoding.UTF8, "application/json")
};
publish.Headers.Authorization = new AuthenticationHeaderValue("Token", File.ReadAllText(tokenFile).Trim());
(await http.SendAsync(publish)).EnsureSuccessStatusCode();
JsonNode article = null;
Assert.True(await Pasture.Eventually(async () =>
(article = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()
.FirstOrDefault(s => s!["privapub"]?["title"]?.GetValue<string>() == title)) is not null, 60), "the article never reached the persona");
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
await Results.Cell("client.render.article", "writefreely", persona.UserName, async () =>
{
await page.GotoAsync($"{Pasture.Site}/");
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
await page.GotoAsync($"{Pasture.Site}/");
var card = page.Locator($"article[data-uri='{article!["uri"]!.GetValue<string>()}']").First;
await card.WaitForAsync(new() { Timeout = 30000 });
await Assertions.Expect(card).ToContainTextAsync(title);
await Assertions.Expect(card.Locator("[data-article]")).ToBeVisibleAsync();
await Assertions.Expect(card).Not.ToContainTextAsync(ending);
await card.Locator("[data-article-open]").ClickAsync();
await Assertions.Expect(card).ToContainTextAsync(ending, new() { Timeout = 5000 });
await session.AssertGuards();
}, page);
await PersonaApi.Call(persona, $"/api/v1/accounts/{blogId}/unfollow", HttpMethod.Post);
}
}
}