Reports, and who liked or boosted a post
Another's profile and posts can be reported from the client (/api/v1/reports): a category, words, and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. Who liked or boosted a post opens from its card's menu (favourited_by, reblogged_by). e2e: a report from a Mastodon account's profile reaches PrivaPub's moderators forwarded, and a post's likes and boosts list the persona who gave them; the admin helper the tests share moves to Support. 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
bb68ff8d5e
commit
a0f07abf8c
17 files changed
+410
-16
No files matched your search
@@ -239,6 +239,9 @@ supplies:
|
|||||||
persona pins and unpins its own public and unlisted posts from the card's menu.
|
persona pins and unpins its own public and unlisted posts from the card's menu.
|
||||||
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
|
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
|
||||||
linking the new account's profile.
|
linking the new account's profile.
|
||||||
|
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
|
||||||
|
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona.
|
||||||
|
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
|
||||||
- **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking
|
- **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking
|
||||||
that profile); one written on the persona's own wall is taken off from the card's menu (`DELETE` on the status, which
|
that profile); one written on the persona's own wall is taken off from the card's menu (`DELETE` on the status, which
|
||||||
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
|
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
|
||||||
|
|||||||
@@ -302,6 +302,20 @@
|
|||||||
<span>@Localizer["Open original"]</span>
|
<span>@Localizer["Open original"]</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
@if (status.FavouritesCount > 0)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="@(() => showInteractedBy = false)" data-liked-by>
|
||||||
|
<i aria-hidden="true" class="ion-md-heart text-base"></i>
|
||||||
|
<span>@Localizer["Liked by"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
@if (status.ReblogsCount > 0)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="@(() => showInteractedBy = true)" data-boosted-by>
|
||||||
|
<i aria-hidden="true" class="ion-md-repeat text-base"></i>
|
||||||
|
<span>@Localizer["Boosted by"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
@if (CanQuote(status))
|
@if (CanQuote(status))
|
||||||
{
|
{
|
||||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleQuote" data-quote>
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleQuote" data-quote>
|
||||||
@@ -318,6 +332,10 @@
|
|||||||
}
|
}
|
||||||
@if (!IsOwn)
|
@if (!IsOwn)
|
||||||
{
|
{
|
||||||
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
|
||||||
|
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
|
||||||
|
<span>@Localizer["Report"]</span>
|
||||||
|
</DButton>
|
||||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
|
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
|
||||||
<i aria-hidden="true" class="ion-md-eye-off text-base text-warning"></i>
|
<i aria-hidden="true" class="ion-md-eye-off text-base text-warning"></i>
|
||||||
<span>@Localizer["Mute"]</span>
|
<span>@Localizer["Mute"]</span>
|
||||||
@@ -370,6 +388,14 @@
|
|||||||
{
|
{
|
||||||
<Composer Editing="status" OnPublished="Edited" />
|
<Composer Editing="status" OnPublished="Edited" />
|
||||||
}
|
}
|
||||||
|
@if (isReporting && status.Account is { } reported)
|
||||||
|
{
|
||||||
|
<ReportDialog Account="reported" Post="status" OnClosed="@(() => isReporting = false)" />
|
||||||
|
}
|
||||||
|
@if (showInteractedBy is { } boosts)
|
||||||
|
{
|
||||||
|
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
|
||||||
|
}
|
||||||
@if (isHistoryOpen)
|
@if (isHistoryOpen)
|
||||||
{
|
{
|
||||||
<DModal IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
|
<DModal IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
|
||||||
@@ -452,6 +478,9 @@
|
|||||||
bool isReadingProvenance;
|
bool isReadingProvenance;
|
||||||
bool isReadingServer;
|
bool isReadingServer;
|
||||||
bool isHistoryOpen;
|
bool isHistoryOpen;
|
||||||
|
bool isReporting;
|
||||||
|
// who liked it (false) or boosted it (true), when that list is open
|
||||||
|
bool? showInteractedBy;
|
||||||
List<StatusEdit> history = [];
|
List<StatusEdit> history = [];
|
||||||
readonly HashSet<string> revealedMedia = [];
|
readonly HashSet<string> revealedMedia = [];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* Who liked a post, or boosted it (/api/v1/statuses/:id/favourited_by, reblogged_by): the accounts PrivaPub knows to
|
||||||
|
have done so, newest first, each opening its profile. *@
|
||||||
|
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
|
||||||
|
<Heading>@(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"])</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
|
||||||
|
}
|
||||||
|
else if (accounts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-sm opacity-70 mt-3">@Localizer["Nobody this server knows of."]</p>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col gap-2 mt-3">
|
||||||
|
@foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
<a class="flex items-center gap-3 p-2 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Acct">
|
||||||
|
@* alt="": the name is next to it. *@
|
||||||
|
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
|
||||||
|
<span class="flex flex-col min-w-0">
|
||||||
|
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
|
||||||
|
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] StatusService StatusService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public string StatusId { get; set; }
|
||||||
|
/// <summary>Who boosted it, rather than who liked it.</summary>
|
||||||
|
[Parameter] public bool Boosts { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
|
||||||
|
List<Account> accounts = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData() => accounts = await StatusService.InteractedBy(StatusId, Boosts);
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@inherits LocalizableComponentBase
|
||||||
|
|
||||||
|
@* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports).
|
||||||
|
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. *@
|
||||||
|
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
|
||||||
|
<Heading>@Localizer["Report {0}", Account.Name]</Heading>
|
||||||
|
<ChildContent>
|
||||||
|
<EditForm Model="form" OnValidSubmit="Send" class="flex flex-col gap-3 mt-3">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<ErrorViewer AdditionalError="@error" />
|
||||||
|
<DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category>
|
||||||
|
<option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option>
|
||||||
|
<option value="@ReportCategory.Legal">@Localizer[nameof(ReportCategory.Legal)]</option>
|
||||||
|
<option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option>
|
||||||
|
</DSelect>
|
||||||
|
<DTextarea TValue="string" @bind-Value="form.Comment" Immediate="true" rows="3" autocomplete="off" maxlength="@ReportForm.CommentLength"
|
||||||
|
placeholder="@Localizer["What is wrong with it?"]" data-report-comment />
|
||||||
|
@if (Host is { } host)
|
||||||
|
{
|
||||||
|
<DCheckbox @bind-Value="form.Forward" LabelText="@Localizer["Send a copy to {0}", host]" data-report-forward />
|
||||||
|
<p class="text-xs opacity-70">@Localizer["The copy comes from the server, never from you."]</p>
|
||||||
|
}
|
||||||
|
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
|
||||||
|
</EditForm>
|
||||||
|
</ChildContent>
|
||||||
|
</DModal>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] AccountService AccountService { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public Account Account { get; set; }
|
||||||
|
/// <summary>The post it was opened from, sent with the report.</summary>
|
||||||
|
[Parameter] public Status Post { get; set; }
|
||||||
|
[Parameter] public EventCallback OnClosed { get; set; }
|
||||||
|
|
||||||
|
ReportForm form = new();
|
||||||
|
bool isSending;
|
||||||
|
string error;
|
||||||
|
|
||||||
|
// the account's own server, for a remote account
|
||||||
|
string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
|
async Task Send()
|
||||||
|
{
|
||||||
|
isSending = true;
|
||||||
|
error = null;
|
||||||
|
var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, Host is not null && form.Forward);
|
||||||
|
isSending = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.AddResponseSuccess(Localizer["Report sent to the moderators."]);
|
||||||
|
await OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(StatusEditForm))]
|
[JsonSerializable(typeof(StatusEditForm))]
|
||||||
[JsonSerializable(typeof(StatusSource))]
|
[JsonSerializable(typeof(StatusSource))]
|
||||||
[JsonSerializable(typeof(List<StatusEdit>))]
|
[JsonSerializable(typeof(List<StatusEdit>))]
|
||||||
|
[JsonSerializable(typeof(ReportRequest))]
|
||||||
[JsonSerializable(typeof(Poll))]
|
[JsonSerializable(typeof(Poll))]
|
||||||
[JsonSerializable(typeof(PollVoteForm))]
|
[JsonSerializable(typeof(PollVoteForm))]
|
||||||
[JsonSerializable(typeof(MastodonList))]
|
[JsonSerializable(typeof(MastodonList))]
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ public static class APIs
|
|||||||
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 GET_M_StatusHistory(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/history";
|
||||||
|
public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by";
|
||||||
|
public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by";
|
||||||
|
public const string POST_Reports = "api/v1/reports";
|
||||||
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";
|
||||||
|
|||||||
@@ -206,6 +206,18 @@ public class StatusSource
|
|||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>/api/v1/reports: an account, and some of its posts, reported to the persona's server's moderators; forwarded,
|
||||||
|
/// a remote account's server gets it too, from the server's own actor.</summary>
|
||||||
|
public class ReportRequest
|
||||||
|
{
|
||||||
|
public string AccountId { get; set; }
|
||||||
|
public List<string> StatusIds { get; set; } = [];
|
||||||
|
public string Comment { get; set; }
|
||||||
|
/// <summary>spam, legal or other.</summary>
|
||||||
|
public string Category { get; set; }
|
||||||
|
public bool Forward { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>/api/v1/statuses/:id/history: one version of a post, the oldest first and the one showing last.</summary>
|
/// <summary>/api/v1/statuses/:id/history: one version of a post, the oldest first and the one showing last.</summary>
|
||||||
public class StatusEdit
|
public class StatusEdit
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
using decePubClient.Resources;
|
||||||
|
|
||||||
|
namespace decePubClient.Models;
|
||||||
|
|
||||||
|
/// <summary>A report of an account, and of the post it was made from, as the report dialog edits it.</summary>
|
||||||
|
public class ReportForm
|
||||||
|
{
|
||||||
|
public const int CommentLength = 1000;
|
||||||
|
|
||||||
|
public ReportCategory Category { get; set; } = ReportCategory.Other;
|
||||||
|
[StringLength(CommentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||||
|
public string Comment { get; set; }
|
||||||
|
/// <summary>A copy to a remote account's own server, from PrivaPub's actor.</summary>
|
||||||
|
public bool Forward { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace decePubClient.Models.Types
|
||||||
|
{
|
||||||
|
/// <summary>Why an account is reported (Mastodon's categories, without its server rules, which PrivaPub has none of).</summary>
|
||||||
|
public enum ReportCategory
|
||||||
|
{
|
||||||
|
Other = 0,
|
||||||
|
Spam = 1,
|
||||||
|
Legal = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,7 +98,15 @@
|
|||||||
<i class="ion-md-remove-circle text-error"></i>
|
<i class="ion-md-remove-circle text-error"></i>
|
||||||
<span>@(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"])</span>
|
<span>@(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"])</span>
|
||||||
</DButton>
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
|
||||||
|
<i class="ion-md-flag text-warning"></i>
|
||||||
|
<span>@Localizer["Report"]</span>
|
||||||
|
</DButton>
|
||||||
</div>
|
</div>
|
||||||
|
@if (isReporting)
|
||||||
|
{
|
||||||
|
<ReportDialog Account="account" OnClosed="@(() => isReporting = false)" />
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -201,6 +209,7 @@
|
|||||||
bool isActing;
|
bool isActing;
|
||||||
bool isLoadingMore;
|
bool isLoadingMore;
|
||||||
bool isListMenuOpen;
|
bool isListMenuOpen;
|
||||||
|
bool isReporting;
|
||||||
List<MastodonList> allLists = [];
|
List<MastodonList> allLists = [];
|
||||||
HashSet<string> inLists = [];
|
HashSet<string> inLists = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1745,4 +1745,34 @@
|
|||||||
<data name="edited" xml:space="preserve">
|
<data name="edited" xml:space="preserve">
|
||||||
<value>modificato</value>
|
<value>modificato</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Report" xml:space="preserve">
|
||||||
|
<value>Segnala</value>
|
||||||
|
</data>
|
||||||
|
<data name="Report {0}" xml:space="preserve">
|
||||||
|
<value>Segnala {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Legal" xml:space="preserve">
|
||||||
|
<value>Contro la legge</value>
|
||||||
|
</data>
|
||||||
|
<data name="What is wrong with it?" xml:space="preserve">
|
||||||
|
<value>Che cosa c'è che non va?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Send a copy to {0}" xml:space="preserve">
|
||||||
|
<value>Invia una copia a {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="The copy comes from the server, never from you." xml:space="preserve">
|
||||||
|
<value>La copia parte dal server, mai da te.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Report sent to the moderators." xml:space="preserve">
|
||||||
|
<value>Segnalazione inviata ai moderatori.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Liked by" xml:space="preserve">
|
||||||
|
<value>Piace a</value>
|
||||||
|
</data>
|
||||||
|
<data name="Boosted by" xml:space="preserve">
|
||||||
|
<value>Condiviso da</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody this server knows of." xml:space="preserve">
|
||||||
|
<value>Nessuno che questo server conosca.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -1745,4 +1745,34 @@
|
|||||||
<data name="edited" xml:space="preserve">
|
<data name="edited" xml:space="preserve">
|
||||||
<value>edited</value>
|
<value>edited</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Report" xml:space="preserve">
|
||||||
|
<value>Report</value>
|
||||||
|
</data>
|
||||||
|
<data name="Report {0}" xml:space="preserve">
|
||||||
|
<value>Report {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Legal" xml:space="preserve">
|
||||||
|
<value>Against the law</value>
|
||||||
|
</data>
|
||||||
|
<data name="What is wrong with it?" xml:space="preserve">
|
||||||
|
<value>What is wrong with it?</value>
|
||||||
|
</data>
|
||||||
|
<data name="Send a copy to {0}" xml:space="preserve">
|
||||||
|
<value>Send a copy to {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="The copy comes from the server, never from you." xml:space="preserve">
|
||||||
|
<value>The copy comes from the server, never from you.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Report sent to the moderators." xml:space="preserve">
|
||||||
|
<value>Report sent to the moderators.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Liked by" xml:space="preserve">
|
||||||
|
<value>Liked by</value>
|
||||||
|
</data>
|
||||||
|
<data name="Boosted by" xml:space="preserve">
|
||||||
|
<value>Boosted by</value>
|
||||||
|
</data>
|
||||||
|
<data name="Nobody this server knows of." xml:space="preserve">
|
||||||
|
<value>Nobody this server knows of.</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -5,6 +5,7 @@ using collAnon.Client.Services;
|
|||||||
using decePubClient.Extensions;
|
using decePubClient.Extensions;
|
||||||
using decePubClient.Models;
|
using decePubClient.Models;
|
||||||
using decePubClient.Models.Mastodon;
|
using decePubClient.Models.Mastodon;
|
||||||
|
using decePubClient.Models.Types;
|
||||||
|
|
||||||
using PrivaPub.ClientModels;
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
@@ -250,6 +251,22 @@ namespace decePubClient.Services
|
|||||||
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server
|
||||||
|
/// gets the report too, from PrivaPub's actor and never naming the persona.</summary>
|
||||||
|
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, bool forward,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest
|
||||||
|
{
|
||||||
|
AccountId = accountId,
|
||||||
|
StatusIds = statusIds,
|
||||||
|
Comment = comment?.Trim(),
|
||||||
|
Category = category.ToString().ToLowerInvariant(),
|
||||||
|
Forward = forward
|
||||||
|
}, cancellationToken: cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WebResult> UnblockDomain(string domain, CancellationToken cancellationToken = default)
|
public async Task<WebResult> UnblockDomain(string domain, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await httpService.Delete(APIs.Mastodon.DELETE_DomainBlocks, [$"domain={Uri.EscapeDataString(domain)}"],
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_DomainBlocks, [$"domain={Uri.EscapeDataString(domain)}"],
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ namespace decePubClient.Services
|
|||||||
return await response.MastodonReadFromJsonAsync<List<StatusEdit>>(cancellationToken) ?? [];
|
return await response.MastodonReadFromJsonAsync<List<StatusEdit>>(cancellationToken) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Who liked a post, or boosted it, as far as PrivaPub knows; [] when it cannot be read.</summary>
|
||||||
|
public async Task<List<Account>> InteractedBy(string statusId, bool boosts, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(boosts ? APIs.Mastodon.GET_M_RebloggedBy(statusId) : APIs.Mastodon.GET_M_FavouritedBy(statusId),
|
||||||
|
["limit=80"], cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(InteractedBy)}", cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<Account>>(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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
using decePubClient.E2E.Support;
|
||||||
@@ -11,22 +10,10 @@ namespace decePubClient.E2E.Actions
|
|||||||
// and unblocked, the federation statistics, and the reports moderators handle; each change checked on /clientapi
|
// and unblocked, the federation statistics, and the reports moderators handle; each change checked on /clientapi
|
||||||
public sealed class AdministrationTests(Browser browser)
|
public sealed class AdministrationTests(Browser browser)
|
||||||
{
|
{
|
||||||
const string Root = "pastureroot";
|
const string Root = PastureAdmin.Root;
|
||||||
const string Password = "Pasture-Pass-1!";
|
static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
|
||||||
static readonly Pasture.Persona Admin = new("alice", "alice", Root, Password, null, null, null);
|
|
||||||
|
|
||||||
static async Task<JsonNode> AsAdmin(string path)
|
static Task<JsonNode> AsAdmin(string path) => PastureAdmin.Get(path);
|
||||||
{
|
|
||||||
using var http = Pasture.Http();
|
|
||||||
var login = await http.PostAsJsonAsync($"{Pasture.Api}/clientapi/user/login", new { userName = Root, password = Password });
|
|
||||||
login.EnsureSuccessStatusCode();
|
|
||||||
var jwt = (await login.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + path);
|
|
||||||
request.Headers.Authorization = new("Bearer", jwt);
|
|
||||||
using var response = await http.SendAsync(request);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<ILocator> Open(IPage page, string section)
|
static async Task<ILocator> Open(IPage page, string section)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Actions
|
||||||
|
{
|
||||||
|
// A persona reports a remote account from its profile, with a category, its words and a copy to the account's server:
|
||||||
|
// PrivaPub's moderators see it, forwarded (/clientapi/moderator/reports). And who liked or boosted a post is listed
|
||||||
|
// from its card
|
||||||
|
public sealed class ReportTests(Browser browser)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task A_remote_account_is_reported_from_its_profile()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().First();
|
||||||
|
var target = (await PersonaApi.Call(persona, "/api/v2/search?q=mastouser%40mastodon.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
|
||||||
|
Assert.SkipWhen(target is null, "no Mastodon: run tools/pasture/run.sh add mastodon");
|
||||||
|
var targetId = target!["id"]!.GetValue<string>();
|
||||||
|
var words = $"reported by the e2e tests {Guid.NewGuid():N}"[..40];
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
await Results.Cell("client.action.report", "mastodon", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/account/{Uri.EscapeDataString(targetId)}");
|
||||||
|
// (the profile's own button: each of its posts' cards has one too)
|
||||||
|
await page.Locator($"article[data-account-id='{targetId}'] button[data-report]").ClickAsync(new() { Timeout = 30000 });
|
||||||
|
var dialog = page.Locator("[data-report-dialog]");
|
||||||
|
await dialog.Locator("[data-report-category]").SelectOptionAsync("Spam");
|
||||||
|
await dialog.Locator("[data-report-comment]").FillAsync(words);
|
||||||
|
await Assertions.Expect(dialog.Locator("[data-report-forward]")).ToBeCheckedAsync();
|
||||||
|
await dialog.Locator("[data-report-send]").ClickAsync();
|
||||||
|
await Assertions.Expect(dialog).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||||
|
|
||||||
|
var report = (await PastureAdmin.Get("/clientapi/moderator/reports?resolved=false"))!.AsArray()
|
||||||
|
.FirstOrDefault(r => r!["comment"]?.GetValue<string>() == words);
|
||||||
|
Assert.NotNull(report);
|
||||||
|
Assert.Equal((targetId, "spam", true), (report!["targetAccountId"]!.GetValue<string>(), report["category"]!.GetValue<string>(),
|
||||||
|
report["forwarded"]!.GetValue<bool>()));
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Who_liked_and_boosted_a_post_is_listed_from_its_card()
|
||||||
|
{
|
||||||
|
var personas = Pasture.Personas();
|
||||||
|
Assert.SkipWhen(personas.Count < 2, "the town has one persona");
|
||||||
|
var (author, fan) = (personas[0], personas[1]);
|
||||||
|
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
||||||
|
new JsonObject { ["status"] = $"like and boost me {Guid.NewGuid():N}"[..28], ["visibility"] = "public" }))!;
|
||||||
|
var id = post["id"]!.GetValue<string>();
|
||||||
|
var fanAcct = (await PersonaApi.Call(fan, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
||||||
|
await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
||||||
|
await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/reblog", HttpMethod.Post);
|
||||||
|
await using var session = await SignIn.As(browser, author);
|
||||||
|
var page = session.Page;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.render.interacted-by", "privapub", author.UserName, async () =>
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/expand/{Uri.EscapeDataString(id)}");
|
||||||
|
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
|
||||||
|
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
foreach (var (button, list) in new[] { ("data-liked-by", "likes"), ("data-boosted-by", "boosts") })
|
||||||
|
{
|
||||||
|
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||||
|
await card.Locator($"button[{button}]").ClickAsync();
|
||||||
|
var dialog = card.Locator($"[data-interacted-by='{list}']");
|
||||||
|
await Assertions.Expect(dialog.Locator($"[data-account='{fanAcct}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
|
||||||
|
await page.Keyboard.PressAsync("Escape");
|
||||||
|
await Assertions.Expect(dialog).ToHaveCountAsync(0);
|
||||||
|
}
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Support
|
||||||
|
{
|
||||||
|
// PrivaPub's /clientapi as the pasture's administrator (pastureroot, whom the scenarios make): what its moderators
|
||||||
|
// and administrators see
|
||||||
|
public static class PastureAdmin
|
||||||
|
{
|
||||||
|
public const string Root = "pastureroot";
|
||||||
|
public const string Password = "Pasture-Pass-1!";
|
||||||
|
|
||||||
|
public static async Task<JsonNode> Get(string path)
|
||||||
|
{
|
||||||
|
using var http = Pasture.Http();
|
||||||
|
var login = await http.PostAsJsonAsync($"{Pasture.Api}/clientapi/user/login", new { userName = Root, password = Password });
|
||||||
|
login.EnsureSuccessStatusCode();
|
||||||
|
var jwt = (await login.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + path);
|
||||||
|
request.Headers.Authorization = new("Bearer", jwt);
|
||||||
|
using var response = await http.SendAsync(request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user