diff --git a/CLAUDE.md b/CLAUDE.md index 9b31f5a..64abf29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,6 +252,9 @@ supplies: 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 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 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. diff --git a/Components/Content.razor b/Components/Content.razor index d6201c6..da2e69b 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -133,7 +133,25 @@

@title

} - @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 *@ +
+ @if (status.Privapub.Cover is { Length: > 0 } cover) + { + + } + @if (ArticleExcerpt(status) is { Length: > 0 } excerpt) + { +

@excerpt

+ } + + @Localizer["Read article"] + +
+ } + else if (status.SpoilerText is { Length: > 0 } spoiler) { @@ -391,6 +409,7 @@ bool isOptionsOpen; bool isSpoilerOpen; bool showFiltered; + bool isArticleOpen; bool isReadingProvenance; bool isReadingServer; readonly HashSet revealedMedia = []; @@ -709,6 +728,26 @@ HashSet 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 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; diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 8499cd4..934be8a 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -1727,4 +1727,7 @@ Togli dal profilo + + Leggi l'articolo + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 8ac028a..f6730ac 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -1727,4 +1727,7 @@ Unpin from profile + + Read article + diff --git a/tests/e2e/Actions/ArticleTests.cs b/tests/e2e/Actions/ArticleTests.cs new file mode 100644 index 0000000..a777dd9 --- /dev/null +++ b/tests/e2e/Actions/ArticleTests.cs @@ -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(); + 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(), 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() == 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()}']").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); + } + } +}