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:
1 parent
03824babbc
commit
bb68ff8d5e
10 files changed
+91
-8
No files matched your search
@@ -261,6 +261,8 @@ supplies:
|
||||
- **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.
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -109,11 +109,12 @@
|
||||
<NerdTip Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" OnOpen="ReadProvenance"
|
||||
Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
|
||||
<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>
|
||||
@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>
|
||||
</span>
|
||||
</header>
|
||||
@@ -369,6 +370,27 @@
|
||||
{
|
||||
<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 isReadingProvenance;
|
||||
bool isReadingServer;
|
||||
bool isHistoryOpen;
|
||||
List<StatusEdit> history = [];
|
||||
readonly HashSet<string> revealedMedia = [];
|
||||
|
||||
Status Status => Post.Status;
|
||||
@@ -895,6 +919,14 @@
|
||||
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)
|
||||
{
|
||||
isEditing = false;
|
||||
|
||||
@@ -36,6 +36,7 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(PostStatusForm))]
|
||||
[JsonSerializable(typeof(StatusEditForm))]
|
||||
[JsonSerializable(typeof(StatusSource))]
|
||||
[JsonSerializable(typeof(List<StatusEdit>))]
|
||||
[JsonSerializable(typeof(Poll))]
|
||||
[JsonSerializable(typeof(PollVoteForm))]
|
||||
[JsonSerializable(typeof(MastodonList))]
|
||||
|
||||
@@ -100,6 +100,7 @@ public static class APIs
|
||||
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 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 const string GET_FollowedTags = "api/v1/followed_tags";
|
||||
public const string GET_GroupedNotifications = "api/v2/notifications";
|
||||
|
||||
@@ -206,6 +206,16 @@ public class StatusSource
|
||||
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>
|
||||
public class ScheduledStatus
|
||||
{
|
||||
|
||||
@@ -1739,4 +1739,10 @@
|
||||
<data name="Taken off your wall." xml:space="preserve">
|
||||
<value>Tolto dalla tua bacheca.</value>
|
||||
</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>
|
||||
@@ -1739,4 +1739,10 @@
|
||||
<data name="Taken off your wall." xml:space="preserve">
|
||||
<value>Taken off your wall.</value>
|
||||
</data>
|
||||
<data name="Edit history" xml:space="preserve">
|
||||
<value>Edit history</value>
|
||||
</data>
|
||||
<data name="edited" xml:space="preserve">
|
||||
<value>edited</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -77,6 +77,19 @@ namespace decePubClient.Services
|
||||
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>
|
||||
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -6,8 +6,8 @@ using Microsoft.Playwright;
|
||||
|
||||
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
|
||||
// PrivaPub
|
||||
// The composer beyond words: a poll in a language picked, the persona's own post edited and its history shown, and a
|
||||
// quote, each checked on PrivaPub
|
||||
public sealed class ComposerTests(Browser browser)
|
||||
{
|
||||
static async Task OpenHome(IPage page)
|
||||
@@ -85,6 +85,14 @@ namespace decePubClient.E2E.Actions
|
||||
await card.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
await Assertions.Expect(card).ToContainTextAsync(text + " (edited)", new() { Timeout = 10000 });
|
||||
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("button[data-quote]").ClickAsync();
|
||||
|
||||
@@ -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");
|
||||
|
||||
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();
|
||||
Assert.False(string.IsNullOrEmpty(written), "Smithereen refused smuser's post on the persona's wall");
|
||||
// (Smithereen lets its followers write there once the persona's Accept has reached it, which may be after
|
||||
// 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;
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(status = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()
|
||||
|
||||
Reference in new issue
Block a user