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:
1 parent
2f29872745
commit
5a7e1804a9
5 files changed
+112
-1
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user