An edited post's versions open from its card

The card marks an edited post with a button that opens its versions, newest first (/statuses/:id/history), each with
its time, warning and words; PrivaPub now has a remote post's earlier versions from Pleroma's formerRepresentations
too. The composer's e2e test opens the history after its edit. The wall test retries smuser's post while Smithereen
has not yet had the persona's Accept.

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 13:03:19 +02:00
1 parent 03824babbc
commit bb68ff8d5e
10 files changed
+91 -8

No files matched your search

+2
View File
@@ -261,6 +261,8 @@ supplies:
- **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.
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
keeps a remote post's earlier versions from its edits and from Pleroma's `formerRepresentations`).
- **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies - **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds
and adds what was found, in the thread's order. and adds what was found, in the thread's order.
+36 -4
View File
@@ -109,11 +109,12 @@
<NerdTip Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" OnOpen="ReadProvenance" <NerdTip Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" OnOpen="ReadProvenance"
Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End"> Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<time datetime="@NerdRow.Time(status.CreatedAt)" class="opacity-80">@status.CreatedAt.GetPassedTime(Localizer)</time> <time datetime="@NerdRow.Time(status.CreatedAt)" class="opacity-80">@status.CreatedAt.GetPassedTime(Localizer)</time>
@if (status.EditedAt is not null)
{
<span class="opacity-60">*</span>
}
</NerdTip> </NerdTip>
@if (status.EditedAt is not null)
{
<button type="button" class="link link-hover opacity-60" data-history title="@Localizer["Edit history"]"
@onclick="() => OpenHistory(status)">@Localizer["edited"]</button>
}
<i aria-hidden="true" class="@VisibilityIcon(status.Visibility) text-base" title="@VisibilityText(status.Visibility)"></i> <i aria-hidden="true" class="@VisibilityIcon(status.Visibility) text-base" title="@VisibilityText(status.Visibility)"></i>
</span> </span>
</header> </header>
@@ -369,6 +370,27 @@
{ {
<Composer Editing="status" OnPublished="Edited" /> <Composer Editing="status" OnPublished="Edited" />
} }
@if (isHistoryOpen)
{
<DModal IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
<Heading>@Localizer["Edit history"]</Heading>
<ChildContent>
<ol class="flex flex-col gap-4 mt-3">
@foreach (var version in Enumerable.Reverse(history))
{
<li class="flex flex-col gap-1" data-version>
<time class="text-xs opacity-70" datetime="@NerdRow.Time(version.CreatedAt)">@version.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</time>
@if (version.SpoilerText is { Length: > 0 } warning)
{
<p class="inline-flex gap-2 items-center text-sm"><i class="ion-md-warning text-warning"></i>@CustomEmojis.Text(warning, status.Emojis)</p>
}
<div class="post-content text-sm break-words">@((MarkupString)CustomEmojis.Html(version.Content, status.Emojis))</div>
</li>
}
</ol>
</ChildContent>
</DModal>
}
} }
} }
} }
@@ -429,6 +451,8 @@
bool isArticleOpen; bool isArticleOpen;
bool isReadingProvenance; bool isReadingProvenance;
bool isReadingServer; bool isReadingServer;
bool isHistoryOpen;
List<StatusEdit> history = [];
readonly HashSet<string> revealedMedia = []; readonly HashSet<string> revealedMedia = [];
Status Status => Post.Status; Status Status => Post.Status;
@@ -895,6 +919,14 @@
await OnReplied.InvokeAsync(quote); await OnReplied.InvokeAsync(quote);
} }
async Task OpenHistory(Status status)
{
isHistoryOpen = true;
history = await StatusService.History(status.Id);
}
void HistoryClosed(bool open) => isHistoryOpen = open;
void Edited(FeedPost edited) void Edited(FeedPost edited)
{ {
isEditing = false; isEditing = false;
+1
View File
@@ -36,6 +36,7 @@ namespace decePubClient;
[JsonSerializable(typeof(PostStatusForm))] [JsonSerializable(typeof(PostStatusForm))]
[JsonSerializable(typeof(StatusEditForm))] [JsonSerializable(typeof(StatusEditForm))]
[JsonSerializable(typeof(StatusSource))] [JsonSerializable(typeof(StatusSource))]
[JsonSerializable(typeof(List<StatusEdit>))]
[JsonSerializable(typeof(Poll))] [JsonSerializable(typeof(Poll))]
[JsonSerializable(typeof(PollVoteForm))] [JsonSerializable(typeof(PollVoteForm))]
[JsonSerializable(typeof(MastodonList))] [JsonSerializable(typeof(MastodonList))]
+1
View File
@@ -100,6 +100,7 @@ public static class APIs
public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}"; public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}";
public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses"; public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses";
public static string GET_M_StatusSource(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/source"; public static string GET_M_StatusSource(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/source";
public static string GET_M_StatusHistory(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/history";
public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}"; public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
public const string GET_FollowedTags = "api/v1/followed_tags"; public const string GET_FollowedTags = "api/v1/followed_tags";
public const string GET_GroupedNotifications = "api/v2/notifications"; public const string GET_GroupedNotifications = "api/v2/notifications";
+10
View File
@@ -206,6 +206,16 @@ public class StatusSource
public string SpoilerText { get; set; } public string SpoilerText { get; set; }
} }
/// <summary>/api/v1/statuses/:id/history: one version of a post, the oldest first and the one showing last.</summary>
public class StatusEdit
{
public string Content { get; set; }
public string SpoilerText { get; set; }
public bool Sensitive { get; set; }
public DateTime CreatedAt { get; set; }
public List<CustomEmoji> Emojis { get; set; } = [];
}
/// <summary>A post waiting for its time (/api/v1/scheduled_statuses).</summary> /// <summary>A post waiting for its time (/api/v1/scheduled_statuses).</summary>
public class ScheduledStatus public class ScheduledStatus
{ {
+6
View File
@@ -1739,4 +1739,10 @@
<data name="Taken off your wall." xml:space="preserve"> <data name="Taken off your wall." xml:space="preserve">
<value>Tolto dalla tua bacheca.</value> <value>Tolto dalla tua bacheca.</value>
</data> </data>
<data name="Edit history" xml:space="preserve">
<value>Cronologia delle modifiche</value>
</data>
<data name="edited" xml:space="preserve">
<value>modificato</value>
</data>
</root> </root>
+6
View File
@@ -1739,4 +1739,10 @@
<data name="Taken off your wall." xml:space="preserve"> <data name="Taken off your wall." xml:space="preserve">
<value>Taken off your wall.</value> <value>Taken off your wall.</value>
</data> </data>
<data name="Edit history" xml:space="preserve">
<value>Edit history</value>
</data>
<data name="edited" xml:space="preserve">
<value>edited</value>
</data>
</root> </root>
+13
View File
@@ -77,6 +77,19 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<StatusSource>(cancellationToken) : null; return response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<StatusSource>(cancellationToken) : null;
} }
/// <summary>A post's versions, the oldest first and the one showing last; [] when they cannot be read.</summary>
public async Task<List<StatusEdit>> History(string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_M_StatusHistory(statusId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(History)}", cancellationToken);
return [];
}
return await response.MastodonReadFromJsonAsync<List<StatusEdit>>(cancellationToken) ?? [];
}
/// <summary>Edits the persona's own post: new words and warning, the media kept; Data is the edited Status.</summary> /// <summary>Edits the persona's own post: new words and warning, the media kept; Data is the edited Status.</summary>
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default) public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
{ {
+10 -2
View File
@@ -6,8 +6,8 @@ using Microsoft.Playwright;
namespace decePubClient.E2E.Actions namespace decePubClient.E2E.Actions
{ {
// The composer beyond words: a poll in a language picked, the persona's own post edited, and a quote, each checked on // The composer beyond words: a poll in a language picked, the persona's own post edited and its history shown, and a
// PrivaPub // quote, each checked on PrivaPub
public sealed class ComposerTests(Browser browser) public sealed class ComposerTests(Browser browser)
{ {
static async Task OpenHome(IPage page) static async Task OpenHome(IPage page)
@@ -85,6 +85,14 @@ namespace decePubClient.E2E.Actions
await card.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync(); await card.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
await Assertions.Expect(card).ToContainTextAsync(text + " (edited)", new() { Timeout = 10000 }); await Assertions.Expect(card).ToContainTextAsync(text + " (edited)", new() { Timeout = 10000 });
Assert.Contains("(edited)", (await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"))!["content"]!.GetValue<string>()); Assert.Contains("(edited)", (await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"))!["content"]!.GetValue<string>());
await card.Locator("button[data-history]").ClickAsync();
var versions = card.Locator("[data-history-list] [data-version]");
await Assertions.Expect(versions).ToHaveCountAsync(2, new() { Timeout = 10000 });
await Assertions.Expect(versions.First).ToContainTextAsync(text + " (edited)");
await Assertions.Expect(versions.Last).ToContainTextAsync(text);
await Assertions.Expect(versions.Last).Not.ToContainTextAsync("(edited)");
await page.Keyboard.PressAsync("Escape");
await Assertions.Expect(card.Locator("[data-history-list]")).ToHaveCountAsync(0);
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync(); await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
await card.Locator("button[data-quote]").ClickAsync(); await card.Locator("button[data-quote]").ClickAsync();
+6 -2
View File
@@ -57,8 +57,12 @@ namespace decePubClient.E2E.Actions
(await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={smuser}"))![0]!["followed_by"]!.GetValue<bool>(), 45), "smuser never followed the persona"); (await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={smuser}"))![0]!["followed_by"]!.GetValue<bool>(), 45), "smuser never followed the persona");
var text = $"a word on the wall {Guid.NewGuid():N}"[..32]; var text = $"a word on the wall {Guid.NewGuid():N}"[..32];
var written = (await Smithereen(token, "wall.post", ("owner_id", personaOnSm), ("message", text)))?["response"]?.ToString(); // (Smithereen lets its followers write there once the persona's Accept has reached it, which may be after
Assert.False(string.IsNullOrEmpty(written), "Smithereen refused smuser's post on the persona's wall"); // PrivaPub counts the follow)
var written = default(string);
Assert.True(await Pasture.Eventually(async () =>
!string.IsNullOrEmpty(written = (await Smithereen(token, "wall.post", ("owner_id", personaOnSm), ("message", text)))?["response"]?.ToString()), 30),
"Smithereen refused smuser's post on the persona's wall");
JsonNode status = null; JsonNode status = null;
Assert.True(await Pasture.Eventually(async () => Assert.True(await Pasture.Eventually(async () =>
(status = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray() (status = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()