Filters: made on /filters, applied by each card where it is
The persona's filters (PrivaPub's /api/v2/filters): made, given words and whole-word rules, contexts, an action and an expiry on /filters (linked from Settings), and deleted; posts filtered one by one can be taken out. Every status says which filters it matches; each page tells its cards where they are (home and lists, notifications, public feeds, the thread around the opened post, profiles), and a card leaves its post out (hide), folds it behind the filter's title with a way to show it anyway (warn), or blurs its media (blur). The e2e test folds and then hides a post of the persona's own. 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
e2491de16e
commit
9a752d9288
18 files changed
+966
-24
No files matched your search
@@ -212,7 +212,7 @@ supplies:
|
|||||||
located and group posts go through `/clientapi/post/insert`), `AccountService` (profiles, relationships, follow,
|
located and group posts go through `/clientapi/post/insert`), `AccountService` (profiles, relationships, follow,
|
||||||
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
|
||||||
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
|
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
|
||||||
`MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`.
|
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`.
|
||||||
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll
|
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll
|
||||||
vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
|
||||||
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice.
|
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice.
|
||||||
@@ -222,6 +222,11 @@ supplies:
|
|||||||
- **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members
|
- **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members
|
||||||
leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists
|
leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists
|
||||||
menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub.
|
menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub.
|
||||||
|
- **Filters** (`Pages/Filters.razor` at `/filters`, linked from Settings): PrivaPub marks every status with the filters it
|
||||||
|
matches (`Status.Filtered`), whatever their context; a page tells each card where it is (`Content.FilterIn`: home and
|
||||||
|
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).
|
||||||
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
|
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
|
||||||
`PrivaPub.ClientModels`.
|
`PrivaPub.ClientModels`.
|
||||||
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
|
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
|
||||||
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
||||||
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id,
|
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id,
|
||||||
which the e2e tests (tests/e2e) find it by. Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on
|
which the e2e tests (tests/e2e) find it by. The persona's filters apply where the page says the card is (FilterIn): a
|
||||||
|
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on
|
||||||
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
||||||
|
@if (!IsFilteredOut)
|
||||||
|
{
|
||||||
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
|
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
|
||||||
data-post-id="@(Compact ? null : Post.Key)" data-lat="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Latitude))"
|
data-post-id="@(Compact ? null : Post.Key)" data-lat="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Latitude))"
|
||||||
data-lng="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Longitude))"
|
data-lng="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Longitude))"
|
||||||
@@ -94,6 +97,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
@if (Warnings.Count > 0 && !showFiltered)
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm rounded-lg neo-inset neo-2xs px-3 py-2" data-filtered>
|
||||||
|
<i class="ion-md-funnel opacity-70" aria-hidden="true"></i>
|
||||||
|
<span class="flex-1 min-w-0 break-words">@Localizer["Filtered: {0}", string.Join(", ", Warnings.Select(w => w.Filter.Title))]</span>
|
||||||
|
<DButton Size="DSize.Xs" OnClick="() => showFiltered = true">@Localizer["Show anyway"]</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
@if (status.Privapub?.Title is { Length: > 0 } title)
|
@if (status.Privapub?.Title is { Length: > 0 } title)
|
||||||
{
|
{
|
||||||
<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>
|
||||||
@@ -221,6 +234,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else if (Post.Notification?.Account is { } follower)
|
else if (Post.Notification?.Account is { } follower)
|
||||||
{
|
{
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
@@ -246,6 +260,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</article>
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Inject] StatusService StatusService { get; set; }
|
[Inject] StatusService StatusService { get; set; }
|
||||||
@@ -262,11 +277,15 @@
|
|||||||
[Parameter] public bool IncludeExpand { get; set; } = true;
|
[Parameter] public bool IncludeExpand { get; set; } = true;
|
||||||
/// <summary>Without actions, e.g. a quoted post.</summary>
|
/// <summary>Without actions, e.g. a quoted post.</summary>
|
||||||
[Parameter] public bool Compact { get; set; }
|
[Parameter] public bool Compact { get; set; }
|
||||||
|
/// <summary>Where the card is shown, for the persona's filters (Status.Filtered): a hide filter of that context leaves
|
||||||
|
/// it out, a warn filter folds it, a blur filter blurs its media. No filter applies without it.</summary>
|
||||||
|
[Parameter] public FilterContext? FilterIn { get; set; }
|
||||||
|
|
||||||
bool isReplying;
|
bool isReplying;
|
||||||
bool isActing;
|
bool isActing;
|
||||||
bool isOptionsOpen;
|
bool isOptionsOpen;
|
||||||
bool isSpoilerOpen;
|
bool isSpoilerOpen;
|
||||||
|
bool showFiltered;
|
||||||
bool isReadingProvenance;
|
bool isReadingProvenance;
|
||||||
bool isReadingServer;
|
bool isReadingServer;
|
||||||
readonly HashSet<string> revealedMedia = [];
|
readonly HashSet<string> revealedMedia = [];
|
||||||
@@ -277,6 +296,16 @@
|
|||||||
InstanceLocation ServerGeo => Server?.Geo;
|
InstanceLocation ServerGeo => Server?.Geo;
|
||||||
bool IsOwn => Post.IsOwnedBy(CascadingState.PersonaId);
|
bool IsOwn => Post.IsOwnedBy(CascadingState.PersonaId);
|
||||||
|
|
||||||
|
List<FilterResult> Matched => FilterIn is { } where && Status is { } status
|
||||||
|
? [.. status.Filtered.Where(r => r.Filter is { IsActive: true } filter && filter.Context.Contains(where))]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
bool IsFilteredOut => Matched.Any(r => r.Filter.FilterAction == FilterAction.Hide);
|
||||||
|
|
||||||
|
List<FilterResult> Warnings => [.. Matched.Where(r => r.Filter.FilterAction == FilterAction.Warn)];
|
||||||
|
|
||||||
|
bool IsBlurred => !showFiltered && Matched.Any(r => r.Filter.FilterAction == FilterAction.Blur);
|
||||||
|
|
||||||
protected override void OnInitialized() => IsLoading = false;
|
protected override void OnInitialized() => IsLoading = false;
|
||||||
|
|
||||||
RenderFragment Body(Status status) => @<div class="flex flex-col gap-3">
|
RenderFragment Body(Status status) => @<div class="flex flex-col gap-3">
|
||||||
@@ -291,11 +320,11 @@
|
|||||||
@foreach (var media in status.MediaAttachments)
|
@foreach (var media in status.MediaAttachments)
|
||||||
{
|
{
|
||||||
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full">
|
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full">
|
||||||
@if (status.Sensitive && !revealedMedia.Contains(media.Id))
|
@if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id))
|
||||||
{
|
{
|
||||||
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm"
|
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm"
|
||||||
@onclick="() => revealedMedia.Add(media.Id)">
|
@onclick="() => revealedMedia.Add(media.Id)">
|
||||||
<i class="ion-md-eye-off mr-2"></i>@Localizer["Sensitive media, show"]
|
<i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ namespace decePubClient;
|
|||||||
[JsonSerializable(typeof(MastodonList))]
|
[JsonSerializable(typeof(MastodonList))]
|
||||||
[JsonSerializable(typeof(List<MastodonList>))]
|
[JsonSerializable(typeof(List<MastodonList>))]
|
||||||
[JsonSerializable(typeof(ListAccountsForm))]
|
[JsonSerializable(typeof(ListAccountsForm))]
|
||||||
|
[JsonSerializable(typeof(Filter))]
|
||||||
|
[JsonSerializable(typeof(List<Filter>))]
|
||||||
|
[JsonSerializable(typeof(FilterForm))]
|
||||||
|
[JsonSerializable(typeof(FilterStatus))]
|
||||||
public partial class MastodonJsonContext : JsonSerializerContext
|
public partial class MastodonJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -70,6 +70,12 @@ public static class APIs
|
|||||||
public static string M_ListAccounts(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}/accounts";
|
public static string M_ListAccounts(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}/accounts";
|
||||||
public static string GET_M_AccountLists(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/lists";
|
public static string GET_M_AccountLists(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/lists";
|
||||||
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_Filters = "api/v2/filters";
|
||||||
|
public const string POST_Filter = "api/v2/filters";
|
||||||
|
public static string PUT_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
|
||||||
|
public static string DELETE_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
|
||||||
|
public static string POST_M_FilterStatus(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}/statuses";
|
||||||
|
public static string DELETE_M_FilterStatus(string filterStatusId) => $"api/v2/filters/statuses/{Uri.EscapeDataString(filterStatusId)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class PrivaPub
|
public static class PrivaPub
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
/// <summary>A persona's filter (Mastodon's v2 filters): words and posts it would rather not see in some contexts, to be
|
||||||
|
/// warned about, hidden or blurred. Statuses carry the filters they match (Status.Filtered); the client applies the
|
||||||
|
/// context and the action.</summary>
|
||||||
|
public class Filter
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public List<FilterContext> Context { get; set; } = [];
|
||||||
|
public DateTime? ExpiresAt { get; set; }
|
||||||
|
public FilterAction FilterAction { get; set; } = FilterAction.Warn;
|
||||||
|
public List<FilterKeyword> Keywords { get; set; } = [];
|
||||||
|
public List<FilterStatus> Statuses { get; set; } = [];
|
||||||
|
|
||||||
|
public bool IsActive => ExpiresAt is null || ExpiresAt > DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterKeyword
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Keyword { get; set; }
|
||||||
|
public bool WholeWord { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterStatus
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string StatusId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterResult
|
||||||
|
{
|
||||||
|
public Filter Filter { get; set; }
|
||||||
|
public List<string> KeywordMatches { get; set; } = [];
|
||||||
|
public List<string> StatusMatches { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Where a filter applies; a card is told which of these it is shown in.</summary>
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<FilterContext>))]
|
||||||
|
public enum FilterContext
|
||||||
|
{
|
||||||
|
[JsonStringEnumMemberName("home")] Home,
|
||||||
|
[JsonStringEnumMemberName("notifications")] Notifications,
|
||||||
|
[JsonStringEnumMemberName("public")] Public,
|
||||||
|
[JsonStringEnumMemberName("thread")] Thread,
|
||||||
|
[JsonStringEnumMemberName("account")] Account
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<FilterAction>))]
|
||||||
|
public enum FilterAction
|
||||||
|
{
|
||||||
|
/// <summary>The post is folded behind the filter's title.</summary>
|
||||||
|
[JsonStringEnumMemberName("warn")] Warn,
|
||||||
|
/// <summary>The post is not shown at all.</summary>
|
||||||
|
[JsonStringEnumMemberName("hide")] Hide,
|
||||||
|
/// <summary>The post's media are blurred, as sensitive media are.</summary>
|
||||||
|
[JsonStringEnumMemberName("blur")] Blur
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What making or changing a filter sends; ExpiresIn 0 means never.</summary>
|
||||||
|
public class FilterForm
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
public List<FilterContext> Context { get; set; } = [];
|
||||||
|
public FilterAction FilterAction { get; set; } = FilterAction.Warn;
|
||||||
|
public int? ExpiresIn { get; set; }
|
||||||
|
public List<FilterKeywordForm> KeywordsAttributes { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilterKeywordForm
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Keyword { get; set; }
|
||||||
|
public bool WholeWord { get; set; }
|
||||||
|
[JsonPropertyName("_destroy")]
|
||||||
|
public bool? Destroy { get; set; }
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ public class Status
|
|||||||
public Quote Quote { get; set; }
|
public Quote Quote { get; set; }
|
||||||
/// <summary>Emoji reactions (PrivaPub sends Pleroma's and Misskey's forms as one list).</summary>
|
/// <summary>Emoji reactions (PrivaPub sends Pleroma's and Misskey's forms as one list).</summary>
|
||||||
public List<EmojiReaction> EmojiReactions { get; set; } = [];
|
public List<EmojiReaction> EmojiReactions { get; set; } = [];
|
||||||
|
/// <summary>The persona's filters this post matches, whatever their context (the card picks those of where it is).</summary>
|
||||||
|
public List<FilterResult> Filtered { get; set; } = [];
|
||||||
public int QuotesCount { get; set; }
|
public int QuotesCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
<Title>@Localizer["Thread"]</Title>
|
<Title>@Localizer["Thread"]</Title>
|
||||||
|
|
||||||
@* A post with its thread (/api/v1/statuses/{id}/context): the posts it answers above, the post, the answers below.
|
@* A post with its thread (/api/v1/statuses/{id}/context): the posts it answers above, the post, the answers below.
|
||||||
The cards do their own actions; a reply or a deletion updates the thread here. *@
|
The cards do their own actions; a reply or a deletion updates the thread here. The persona's thread filters apply
|
||||||
|
to the posts around the one it opened, never to that one. *@
|
||||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
@@ -32,14 +33,14 @@
|
|||||||
{
|
{
|
||||||
@foreach (var post in ancestors)
|
@foreach (var post in ancestors)
|
||||||
{
|
{
|
||||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||||
}
|
}
|
||||||
|
|
||||||
<Content @key="current.Key" Post="current" Highlighted="true" IncludeExpand="false" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
<Content @key="current.Key" Post="current" Highlighted="true" IncludeExpand="false" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||||
|
|
||||||
@foreach (var post in descendants)
|
@foreach (var post in descendants)
|
||||||
{
|
{
|
||||||
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
<Content @key="post.Key" Post="post" CssContainer="ml-6 w-auto" FilterIn="FilterContext.Thread" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
@page "/filters"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inherits LocalizableComponentBase
|
||||||
|
<Title>@Localizer["Filters"]</Title>
|
||||||
|
|
||||||
|
@* The current persona's filters (/api/v2/filters): words, phrases and posts it would rather not see, where (home and
|
||||||
|
lists, notifications, the public feeds, threads, profiles), how (folded behind the filter's title, left out, or with
|
||||||
|
media blurred) and until when. PrivaPub marks every post with the filters it matches; the cards apply them
|
||||||
|
(Content.FilterIn). Filters are the persona's own and never leave PrivaPub. *@
|
||||||
|
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||||
|
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="ion-md-funnel text-lg"></i>
|
||||||
|
<h1 class="font-bold truncate">@Localizer["Filters"]</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="draft" OnValidSubmit="Create" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="draft.Title" Immediate="true" autocomplete="off" maxlength="200"
|
||||||
|
placeholder="@Localizer["A new filter's title"]" data-filter-new>
|
||||||
|
<Prefix><i class="ion-md-add"></i></Prefix>
|
||||||
|
</DInput>
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(draft.Title))">
|
||||||
|
<span>@Localizer["Make a filter"]</span>
|
||||||
|
</DButton>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<p class="flex w-full items-center justify-center gap-2">
|
||||||
|
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||||
|
@Localizer["loading"]
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (filters.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="w-full text-center" data-feed-empty><i class="ion-ios-remove-circle-outline"></i> @Localizer["This persona has no filter yet."]</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach (var filter in filters)
|
||||||
|
{
|
||||||
|
<article class="flex flex-col gap-3 p-3 md:p-4 rounded-xl neo-raised neo-xs" data-filter-id="@filter.Id">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<b class="flex-1 min-w-0 truncate">@filter.Title</b>
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@ActionName(filter.FilterAction)</DBadge>
|
||||||
|
@if (!filter.IsActive)
|
||||||
|
{
|
||||||
|
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Expired"]</DBadge>
|
||||||
|
}
|
||||||
|
else if (filter.ExpiresAt is { } expires)
|
||||||
|
{
|
||||||
|
<span class="text-xs opacity-70" title="@NerdRow.Time(expires)">@Localizer["until {0}", expires.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]</span>
|
||||||
|
}
|
||||||
|
<span class="text-xs opacity-70 tabular-nums">@Localizer["{0} words, {1} posts", filter.Keywords.Count, filter.Statuses.Count]</span>
|
||||||
|
<DButton Size="DSize.Sm" Active="@(open?.Id == filter.Id)" OnClick="@(() => Toggle(filter))" title="@Localizer["Edit"]">
|
||||||
|
<i class="ion-md-create"></i><span class="hidden sm:inline">@Localizer["Edit"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (open?.Id == filter.Id)
|
||||||
|
{
|
||||||
|
<EditForm Model="edit" OnValidSubmit="Save" class="flex flex-col gap-3">
|
||||||
|
<DInput TValue="string" class="w-full" @bind-Value="edit.Title" Immediate="true" autocomplete="off" maxlength="200"
|
||||||
|
aria-label="@Localizer["Title"]" data-filter-title />
|
||||||
|
|
||||||
|
<fieldset class="flex flex-wrap gap-x-4 gap-y-2">
|
||||||
|
<legend class="text-sm font-semibold pb-1">@Localizer["Where"]</legend>
|
||||||
|
@foreach (var context in Contexts)
|
||||||
|
{
|
||||||
|
<label class="label text-sm">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-sm" checked="@edit.Contexts.Contains(context)"
|
||||||
|
@onchange="@((ChangeEventArgs e) => ToggleContext(context, e.Value is true))" />
|
||||||
|
<span>@ContextName(context)</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DSelect TValue="FilterAction" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.Action" aria-label="@Localizer["How"]">
|
||||||
|
<Prefix><i class="ion-md-funnel"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var action in Actions)
|
||||||
|
{
|
||||||
|
<option value="@action">@ActionText(action)</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
<DSelect TValue="int" Size="DSize.Sm" class="w-full sm:w-auto" @bind-Value="edit.ExpiresIn" aria-label="@Localizer["Expires"]">
|
||||||
|
<Prefix><i class="ion-md-time"></i></Prefix>
|
||||||
|
<ChildContent>
|
||||||
|
@foreach (var (seconds, label) in Expiries)
|
||||||
|
{
|
||||||
|
<option value="@seconds">@label</option>
|
||||||
|
}
|
||||||
|
</ChildContent>
|
||||||
|
</DSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="font-semibold text-sm">@Localizer["Words"]</h2>
|
||||||
|
<div class="flex flex-col gap-2" data-filter-keywords>
|
||||||
|
@foreach (var row in edit.Keywords.Where(k => !k.Removed))
|
||||||
|
{
|
||||||
|
<div class="flex flex-wrap items-center gap-2" data-keyword="@row.Keyword">
|
||||||
|
<span class="flex-1 min-w-0 break-words text-sm">@row.Keyword</span>
|
||||||
|
<label class="label text-xs">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" checked="@row.WholeWord" @onchange="@((ChangeEventArgs e) => row.WholeWord = e.Value is true)" />
|
||||||
|
<span>@Localizer["Whole word"]</span>
|
||||||
|
</label>
|
||||||
|
<DButton Size="DSize.Xs" OnClick="@(() => RemoveKeyword(row))" title="@Localizer["Remove"]">
|
||||||
|
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
|
<DInput TValue="string" class="flex-1 min-w-48" @bind-Value="edit.NewKeyword" Immediate="true" autocomplete="off" maxlength="100"
|
||||||
|
placeholder="@Localizer["A word or a phrase"]" data-filter-keyword-new />
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(string.IsNullOrWhiteSpace(edit.NewKeyword))" OnClick="AddKeyword">
|
||||||
|
<i class="ion-md-add"></i><span>@Localizer["Add"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filter.Statuses.Count > 0)
|
||||||
|
{
|
||||||
|
<h2 class="font-semibold text-sm">@Localizer["Posts filtered one by one"]</h2>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
@foreach (var item in filter.Statuses)
|
||||||
|
{
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<a class="link flex-1 min-w-0 truncate" href="@($"expand/{Uri.EscapeDataString(item.StatusId)}")">@item.StatusId</a>
|
||||||
|
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="@(() => RemoveStatus(filter, item))">
|
||||||
|
<i class="ion-md-close"></i><span class="hidden sm:inline">@Localizer["Remove"]</span>
|
||||||
|
</DButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary"
|
||||||
|
Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(edit.Title) || edit.Contexts.Count == 0)">
|
||||||
|
<i class="ion-md-checkmark"></i><span>@Localizer["Save"]</span>
|
||||||
|
</DButton>
|
||||||
|
@if (isConfirmingDelete)
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="Delete">
|
||||||
|
<i class="ion-md-trash"></i><span>@Localizer["Delete it for good"]</span>
|
||||||
|
</DButton>
|
||||||
|
<DButton Size="DSize.Sm" OnClick="() => isConfirmingDelete = false">@Localizer["Cancel"]</DButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="() => isConfirmingDelete = true">
|
||||||
|
<i class="ion-md-trash text-error"></i><span>@Localizer["Delete"]</span>
|
||||||
|
</DButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
const int KeepExpiry = -1;
|
||||||
|
static readonly FilterContext[] Contexts = [FilterContext.Home, FilterContext.Notifications, FilterContext.Public, FilterContext.Thread, FilterContext.Account];
|
||||||
|
static readonly FilterAction[] Actions = [FilterAction.Warn, FilterAction.Hide, FilterAction.Blur];
|
||||||
|
|
||||||
|
[Inject] FilterService FilterService { get; set; }
|
||||||
|
|
||||||
|
List<Filter> filters = [];
|
||||||
|
TitleForm draft = new();
|
||||||
|
FilterEdit edit = new();
|
||||||
|
Filter open;
|
||||||
|
string loadedPersonaId;
|
||||||
|
bool isSaving;
|
||||||
|
bool isConfirmingDelete;
|
||||||
|
|
||||||
|
(int Seconds, string Label)[] Expiries =>
|
||||||
|
[
|
||||||
|
(KeepExpiry, Localizer["Keep its expiry"]),
|
||||||
|
(0, Localizer["Never expires"]),
|
||||||
|
(1800, Localizer["Expires in 30 minutes"]),
|
||||||
|
(3600, Localizer["Expires in 1 hour"]),
|
||||||
|
(43200, Localizer["Expires in 12 hours"]),
|
||||||
|
(86400, Localizer["Expires in 1 day"]),
|
||||||
|
(604800, Localizer["Expires in 1 week"])
|
||||||
|
];
|
||||||
|
|
||||||
|
string ContextName(FilterContext context) => context switch
|
||||||
|
{
|
||||||
|
FilterContext.Home => Localizer["Home and lists"],
|
||||||
|
FilterContext.Notifications => Localizer["Notifications"],
|
||||||
|
FilterContext.Public => Localizer["Public feeds"],
|
||||||
|
FilterContext.Thread => Localizer["Threads"],
|
||||||
|
_ => Localizer["Profiles"]
|
||||||
|
};
|
||||||
|
|
||||||
|
string ActionName(FilterAction action) => action switch
|
||||||
|
{
|
||||||
|
FilterAction.Hide => Localizer["Hide"],
|
||||||
|
FilterAction.Blur => Localizer["Blur"],
|
||||||
|
_ => Localizer["Warn"]
|
||||||
|
};
|
||||||
|
|
||||||
|
string ActionText(FilterAction action) => action switch
|
||||||
|
{
|
||||||
|
FilterAction.Hide => Localizer["Leave the post out"],
|
||||||
|
FilterAction.Blur => Localizer["Blur its media"],
|
||||||
|
_ => Localizer["Fold the post behind the filter's title"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// The persona changed (NavMenu's switcher): its filters are another persona's.
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (CascadingState.PersonaId is null)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CascadingState.PersonaId == loadedPersonaId)
|
||||||
|
return;
|
||||||
|
loadedPersonaId = CascadingState.PersonaId;
|
||||||
|
|
||||||
|
if (CascadingState.IsOffline)
|
||||||
|
{
|
||||||
|
WhenOnline(LoadData);
|
||||||
|
StartOnlinePolling();
|
||||||
|
IsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadData()
|
||||||
|
{
|
||||||
|
IsLoading = true;
|
||||||
|
open = null;
|
||||||
|
filters = await FilterService.All();
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a new filter applies everywhere and warns until it is told otherwise
|
||||||
|
async Task Create()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.Save(null, new FilterForm { Title = draft.Title.Trim(), Context = [.. Contexts], FilterAction = FilterAction.Warn });
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filter = (Filter)result.Data;
|
||||||
|
filters.Add(filter);
|
||||||
|
draft = new();
|
||||||
|
Toast.AddResponseSuccess(Localizer["Filter {0} made.", filter.Title]);
|
||||||
|
Toggle(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Toggle(Filter filter)
|
||||||
|
{
|
||||||
|
isConfirmingDelete = false;
|
||||||
|
if (open?.Id == filter.Id)
|
||||||
|
{
|
||||||
|
open = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
open = filter;
|
||||||
|
edit = new FilterEdit
|
||||||
|
{
|
||||||
|
Title = filter.Title,
|
||||||
|
Contexts = [.. filter.Context],
|
||||||
|
Action = filter.FilterAction,
|
||||||
|
ExpiresIn = KeepExpiry,
|
||||||
|
Keywords = [.. filter.Keywords.Select(k => new KeywordRow { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord, WasWholeWord = k.WholeWord })]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void ToggleContext(FilterContext context, bool on)
|
||||||
|
{
|
||||||
|
if (on)
|
||||||
|
edit.Contexts.Add(context);
|
||||||
|
else
|
||||||
|
edit.Contexts.Remove(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddKeyword()
|
||||||
|
{
|
||||||
|
var keyword = edit.NewKeyword?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(keyword) || edit.Keywords.Any(k => !k.Removed && string.Equals(k.Keyword, keyword, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return;
|
||||||
|
edit.Keywords.Add(new KeywordRow { Keyword = keyword, WholeWord = true });
|
||||||
|
edit.NewKeyword = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveKeyword(KeywordRow row)
|
||||||
|
{
|
||||||
|
if (row.Id is null)
|
||||||
|
edit.Keywords.Remove(row);
|
||||||
|
else
|
||||||
|
row.Removed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Save()
|
||||||
|
{
|
||||||
|
AddKeyword();
|
||||||
|
var form = new FilterForm
|
||||||
|
{
|
||||||
|
Title = edit.Title.Trim(),
|
||||||
|
Context = [.. Contexts.Where(edit.Contexts.Contains)],
|
||||||
|
FilterAction = edit.Action,
|
||||||
|
ExpiresIn = edit.ExpiresIn == KeepExpiry ? null : edit.ExpiresIn,
|
||||||
|
KeywordsAttributes =
|
||||||
|
[
|
||||||
|
.. edit.Keywords.Where(k => k.Id is not null && k.Removed).Select(k => new FilterKeywordForm { Id = k.Id, Destroy = true }),
|
||||||
|
.. edit.Keywords.Where(k => k.Id is not null && !k.Removed && k.WholeWord != k.WasWholeWord)
|
||||||
|
.Select(k => new FilterKeywordForm { Id = k.Id, Keyword = k.Keyword, WholeWord = k.WholeWord }),
|
||||||
|
.. edit.Keywords.Where(k => k.Id is null).Select(k => new FilterKeywordForm { Keyword = k.Keyword, WholeWord = k.WholeWord })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.Save(open.Id, form);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = (Filter)result.Data;
|
||||||
|
var index = filters.FindIndex(f => f.Id == saved.Id);
|
||||||
|
if (index >= 0)
|
||||||
|
filters[index] = saved;
|
||||||
|
open = null;
|
||||||
|
Toast.AddResponseSuccess(Localizer["Filter {0} saved.", saved.Title]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task Delete()
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.Delete(open.Id);
|
||||||
|
isSaving = false;
|
||||||
|
isConfirmingDelete = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.RemoveAll(f => f.Id == open.Id);
|
||||||
|
Toast.AddResponseSuccess(Localizer["Filter {0} deleted.", open.Title]);
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RemoveStatus(Filter filter, FilterStatus item)
|
||||||
|
{
|
||||||
|
isSaving = true;
|
||||||
|
var result = await FilterService.RemoveStatus(item.Id);
|
||||||
|
isSaving = false;
|
||||||
|
if (result.IsInvalid)
|
||||||
|
{
|
||||||
|
Toast.AddResponseError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filter.Statuses.Remove(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class TitleForm
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class FilterEdit
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
public HashSet<FilterContext> Contexts { get; set; } = [];
|
||||||
|
public FilterAction Action { get; set; } = FilterAction.Warn;
|
||||||
|
public int ExpiresIn { get; set; } = KeepExpiry;
|
||||||
|
public List<KeywordRow> Keywords { get; set; } = [];
|
||||||
|
public string NewKeyword { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class KeywordRow
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Keyword { get; set; }
|
||||||
|
public bool WholeWord { get; set; }
|
||||||
|
public bool WasWholeWord { get; set; }
|
||||||
|
public bool Removed { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
@foreach (var post in posts)
|
@foreach (var post in posts)
|
||||||
{
|
{
|
||||||
<Content @key="post.Key" Post="post" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||||
}
|
}
|
||||||
|
|
||||||
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
|
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
|
||||||
@@ -175,6 +175,16 @@
|
|||||||
_ = PollNewer(pageCancellation.Token);
|
_ = PollNewer(pageCancellation.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Which of the persona's filters apply to this feed: home's for home and lists, public's for the shared
|
||||||
|
/// feeds, none for the persona's own collections (favourites, bookmarks) and conversations.</summary>
|
||||||
|
FilterContext? FilterIn => Filter.TimelineType switch
|
||||||
|
{
|
||||||
|
TimelineType.Home or TimelineType.Lists => FilterContext.Home,
|
||||||
|
TimelineType.Local or TimelineType.Federation or TimelineType.Tag or TimelineType.Nearby or TimelineType.Groups => FilterContext.Public,
|
||||||
|
TimelineType.Notifications => FilterContext.Notifications,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
|
FeedQuery Query => new(Filter.TimelineType, CascadingState.PersonaId, Filter.GroupId, groups.FirstOrDefault(g => g.Id == Filter.GroupId)?.IsCommunity == true,
|
||||||
Filter.Tag, position?.Latitude, position?.Longitude, Filter.ListId);
|
Filter.Tag, position?.Latitude, position?.Longitude, Filter.ListId);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -141,7 +141,7 @@
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
@foreach (var post in posts)
|
@foreach (var post in posts)
|
||||||
{
|
{
|
||||||
<Content @key="post.Key" Post="post" OnDeleted="Remove" OnTag="OpenTag" />
|
<Content @key="post.Key" Post="post" FilterIn="FilterContext.Account" OnDeleted="Remove" OnTag="OpenTag" />
|
||||||
}
|
}
|
||||||
@if (posts.Count == 0 && !isLoadingMore)
|
@if (posts.Count == 0 && !isLoadingMore)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,6 +46,20 @@
|
|||||||
</ChildContent>
|
</ChildContent>
|
||||||
</DCollapse>
|
</DCollapse>
|
||||||
|
|
||||||
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
|
<Header>
|
||||||
|
<p class="inline-flex items-center gap-2">
|
||||||
|
<i class="ion-md-funnel text-xl"></i> <span>@Localizer["Filters"]</span>
|
||||||
|
</p>
|
||||||
|
</Header>
|
||||||
|
<ChildContent>
|
||||||
|
<div class="flex flex-wrap items-center gap-3 w-full p-3 md:p-4">
|
||||||
|
<p class="flex-1 min-w-48 text-sm">@Localizer["Words, phrases and posts this persona would rather not see, and where."]</p>
|
||||||
|
<a class="btn btn-sm" href="filters"><i class="ion-md-funnel"></i><span>@Localizer["Manage filters"]</span></a>
|
||||||
|
</div>
|
||||||
|
</ChildContent>
|
||||||
|
</DCollapse>
|
||||||
|
|
||||||
<DCollapse Icon="DCollapseIcon.Arrow">
|
<DCollapse Icon="DCollapseIcon.Arrow">
|
||||||
<Header>
|
<Header>
|
||||||
<p class="inline-flex items-center gap-2">
|
<p class="inline-flex items-center gap-2">
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ builder.Services.AddOptions()
|
|||||||
.AddScoped<StatusService>()
|
.AddScoped<StatusService>()
|
||||||
.AddScoped<AccountService>()
|
.AddScoped<AccountService>()
|
||||||
.AddScoped<ListService>()
|
.AddScoped<ListService>()
|
||||||
|
.AddScoped<FilterService>()
|
||||||
.AddScoped<PublicNoticeService>()
|
.AddScoped<PublicNoticeService>()
|
||||||
.AddScoped<MediaService>()
|
.AddScoped<MediaService>()
|
||||||
.AddScoped<GroupService>()
|
.AddScoped<GroupService>()
|
||||||
|
|||||||
@@ -989,4 +989,121 @@
|
|||||||
<data name="{0} removed from {1}." xml:space="preserve">
|
<data name="{0} removed from {1}." xml:space="preserve">
|
||||||
<value>{0} tolto da {1}.</value>
|
<value>{0} tolto da {1}.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Filters" xml:space="preserve">
|
||||||
|
<value>Filtri</value>
|
||||||
|
</data>
|
||||||
|
<data name="A new filter's title" xml:space="preserve">
|
||||||
|
<value>Il titolo di un nuovo filtro</value>
|
||||||
|
</data>
|
||||||
|
<data name="Make a filter" xml:space="preserve">
|
||||||
|
<value>Crea un filtro</value>
|
||||||
|
</data>
|
||||||
|
<data name="This persona has no filter yet." xml:space="preserve">
|
||||||
|
<value>Questa persona non ha ancora filtri.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expired" xml:space="preserve">
|
||||||
|
<value>Scaduto</value>
|
||||||
|
</data>
|
||||||
|
<data name="until {0}" xml:space="preserve">
|
||||||
|
<value>fino al {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} words, {1} posts" xml:space="preserve">
|
||||||
|
<value>{0} parole, {1} post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Where" xml:space="preserve">
|
||||||
|
<value>Dove</value>
|
||||||
|
</data>
|
||||||
|
<data name="How" xml:space="preserve">
|
||||||
|
<value>Come</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires" xml:space="preserve">
|
||||||
|
<value>Scadenza</value>
|
||||||
|
</data>
|
||||||
|
<data name="Words" xml:space="preserve">
|
||||||
|
<value>Parole</value>
|
||||||
|
</data>
|
||||||
|
<data name="Whole word" xml:space="preserve">
|
||||||
|
<value>Parola intera</value>
|
||||||
|
</data>
|
||||||
|
<data name="A word or a phrase" xml:space="preserve">
|
||||||
|
<value>Una parola o una frase</value>
|
||||||
|
</data>
|
||||||
|
<data name="Posts filtered one by one" xml:space="preserve">
|
||||||
|
<value>Post filtrati uno per uno</value>
|
||||||
|
</data>
|
||||||
|
<data name="Keep its expiry" xml:space="preserve">
|
||||||
|
<value>Mantieni la scadenza</value>
|
||||||
|
</data>
|
||||||
|
<data name="Never expires" xml:space="preserve">
|
||||||
|
<value>Non scade mai</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires in 30 minutes" xml:space="preserve">
|
||||||
|
<value>Scade tra 30 minuti</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires in 1 hour" xml:space="preserve">
|
||||||
|
<value>Scade tra 1 ora</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires in 12 hours" xml:space="preserve">
|
||||||
|
<value>Scade tra 12 ore</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires in 1 day" xml:space="preserve">
|
||||||
|
<value>Scade tra 1 giorno</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires in 1 week" xml:space="preserve">
|
||||||
|
<value>Scade tra 1 settimana</value>
|
||||||
|
</data>
|
||||||
|
<data name="Home and lists" xml:space="preserve">
|
||||||
|
<value>Home e liste</value>
|
||||||
|
</data>
|
||||||
|
<data name="Public feeds" xml:space="preserve">
|
||||||
|
<value>Feed pubblici</value>
|
||||||
|
</data>
|
||||||
|
<data name="Threads" xml:space="preserve">
|
||||||
|
<value>Discussioni</value>
|
||||||
|
</data>
|
||||||
|
<data name="Profiles" xml:space="preserve">
|
||||||
|
<value>Profili</value>
|
||||||
|
</data>
|
||||||
|
<data name="Hide" xml:space="preserve">
|
||||||
|
<value>Nascondi</value>
|
||||||
|
</data>
|
||||||
|
<data name="Blur" xml:space="preserve">
|
||||||
|
<value>Sfoca</value>
|
||||||
|
</data>
|
||||||
|
<data name="Warn" xml:space="preserve">
|
||||||
|
<value>Avvisa</value>
|
||||||
|
</data>
|
||||||
|
<data name="Leave the post out" xml:space="preserve">
|
||||||
|
<value>Togli il post</value>
|
||||||
|
</data>
|
||||||
|
<data name="Blur its media" xml:space="preserve">
|
||||||
|
<value>Sfoca i suoi media</value>
|
||||||
|
</data>
|
||||||
|
<data name="Fold the post behind the filter's title" xml:space="preserve">
|
||||||
|
<value>Piega il post dietro il titolo del filtro</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filter {0} made." xml:space="preserve">
|
||||||
|
<value>Filtro {0} creato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filter {0} saved." xml:space="preserve">
|
||||||
|
<value>Filtro {0} salvato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filter {0} deleted." xml:space="preserve">
|
||||||
|
<value>Filtro {0} eliminato.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filtered: {0}" xml:space="preserve">
|
||||||
|
<value>Filtrato: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Show anyway" xml:space="preserve">
|
||||||
|
<value>Mostra comunque</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filtered media, show" xml:space="preserve">
|
||||||
|
<value>Media filtrati, mostra</value>
|
||||||
|
</data>
|
||||||
|
<data name="Words, phrases and posts this persona would rather not see, and where." xml:space="preserve">
|
||||||
|
<value>Parole, frasi e post che questa persona preferisce non vedere, e dove.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Manage filters" xml:space="preserve">
|
||||||
|
<value>Gestisci i filtri</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -989,4 +989,121 @@
|
|||||||
<data name="{0} removed from {1}." xml:space="preserve">
|
<data name="{0} removed from {1}." xml:space="preserve">
|
||||||
<value>{0} removed from {1}.</value>
|
<value>{0} removed from {1}.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Filters" xml:space="preserve">
|
||||||
|
<value>Filters</value>
|
||||||
|
</data>
|
||||||
|
<data name="A new filter's title" xml:space="preserve">
|
||||||
|
<value>A new filter's title</value>
|
||||||
|
</data>
|
||||||
|
<data name="Make a filter" xml:space="preserve">
|
||||||
|
<value>Make a filter</value>
|
||||||
|
</data>
|
||||||
|
<data name="This persona has no filter yet." xml:space="preserve">
|
||||||
|
<value>This persona has no filter yet.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expired" xml:space="preserve">
|
||||||
|
<value>Expired</value>
|
||||||
|
</data>
|
||||||
|
<data name="until {0}" xml:space="preserve">
|
||||||
|
<value>until {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="{0} words, {1} posts" xml:space="preserve">
|
||||||
|
<value>{0} words, {1} posts</value>
|
||||||
|
</data>
|
||||||
|
<data name="Where" xml:space="preserve">
|
||||||
|
<value>Where</value>
|
||||||
|
</data>
|
||||||
|
<data name="How" xml:space="preserve">
|
||||||
|
<value>How</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires" xml:space="preserve">
|
||||||
|
<value>Expires</value>
|
||||||
|
</data>
|
||||||
|
<data name="Words" xml:space="preserve">
|
||||||
|
<value>Words</value>
|
||||||
|
</data>
|
||||||
|
<data name="Whole word" xml:space="preserve">
|
||||||
|
<value>Whole word</value>
|
||||||
|
</data>
|
||||||
|
<data name="A word or a phrase" xml:space="preserve">
|
||||||
|
<value>A word or a phrase</value>
|
||||||
|
</data>
|
||||||
|
<data name="Posts filtered one by one" xml:space="preserve">
|
||||||
|
<value>Posts filtered one by one</value>
|
||||||
|
</data>
|
||||||
|
<data name="Keep its expiry" xml:space="preserve">
|
||||||
|
<value>Keep its expiry</value>
|
||||||
|
</data>
|
||||||
|
<data name="Never expires" xml:space="preserve">
|
||||||
|
<value>Never expires</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires in 30 minutes" xml:space="preserve">
|
||||||
|
<value>Expires in 30 minutes</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires in 1 hour" xml:space="preserve">
|
||||||
|
<value>Expires in 1 hour</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires in 12 hours" xml:space="preserve">
|
||||||
|
<value>Expires in 12 hours</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires in 1 day" xml:space="preserve">
|
||||||
|
<value>Expires in 1 day</value>
|
||||||
|
</data>
|
||||||
|
<data name="Expires in 1 week" xml:space="preserve">
|
||||||
|
<value>Expires in 1 week</value>
|
||||||
|
</data>
|
||||||
|
<data name="Home and lists" xml:space="preserve">
|
||||||
|
<value>Home and lists</value>
|
||||||
|
</data>
|
||||||
|
<data name="Public feeds" xml:space="preserve">
|
||||||
|
<value>Public feeds</value>
|
||||||
|
</data>
|
||||||
|
<data name="Threads" xml:space="preserve">
|
||||||
|
<value>Threads</value>
|
||||||
|
</data>
|
||||||
|
<data name="Profiles" xml:space="preserve">
|
||||||
|
<value>Profiles</value>
|
||||||
|
</data>
|
||||||
|
<data name="Hide" xml:space="preserve">
|
||||||
|
<value>Hide</value>
|
||||||
|
</data>
|
||||||
|
<data name="Blur" xml:space="preserve">
|
||||||
|
<value>Blur</value>
|
||||||
|
</data>
|
||||||
|
<data name="Warn" xml:space="preserve">
|
||||||
|
<value>Warn</value>
|
||||||
|
</data>
|
||||||
|
<data name="Leave the post out" xml:space="preserve">
|
||||||
|
<value>Leave the post out</value>
|
||||||
|
</data>
|
||||||
|
<data name="Blur its media" xml:space="preserve">
|
||||||
|
<value>Blur its media</value>
|
||||||
|
</data>
|
||||||
|
<data name="Fold the post behind the filter's title" xml:space="preserve">
|
||||||
|
<value>Fold the post behind the filter's title</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filter {0} made." xml:space="preserve">
|
||||||
|
<value>Filter {0} made.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filter {0} saved." xml:space="preserve">
|
||||||
|
<value>Filter {0} saved.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filter {0} deleted." xml:space="preserve">
|
||||||
|
<value>Filter {0} deleted.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filtered: {0}" xml:space="preserve">
|
||||||
|
<value>Filtered: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="Show anyway" xml:space="preserve">
|
||||||
|
<value>Show anyway</value>
|
||||||
|
</data>
|
||||||
|
<data name="Filtered media, show" xml:space="preserve">
|
||||||
|
<value>Filtered media, show</value>
|
||||||
|
</data>
|
||||||
|
<data name="Words, phrases and posts this persona would rather not see, and where." xml:space="preserve">
|
||||||
|
<value>Words, phrases and posts this persona would rather not see, and where.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Manage filters" xml:space="preserve">
|
||||||
|
<value>Manage filters</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using collAnon.Client.Services;
|
||||||
|
|
||||||
|
using decePubClient.Extensions;
|
||||||
|
using decePubClient.Models;
|
||||||
|
using decePubClient.Models.Mastodon;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
|
||||||
|
namespace decePubClient.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The current persona's filters (PrivaPub's /api/v2/filters): made, changed with their keywords, given posts, and
|
||||||
|
/// deleted. What they match comes on each status (Status.Filtered); the card applies it. Lists come back empty on
|
||||||
|
/// failure; nothing throws.
|
||||||
|
/// </summary>
|
||||||
|
public class FilterService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||||
|
{
|
||||||
|
public async Task<List<Filter>> All(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Get(APIs.Mastodon.GET_Filters, cancellationToken: cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.MastodonReadFromJsonAsync<List<Filter>>(cancellationToken) ?? [];
|
||||||
|
|
||||||
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
|
||||||
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(FilterService)}.{nameof(All)}", cancellationToken);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes the filter when filterId is null, or changes it; Data is the filter as PrivaPub keeps it.</summary>
|
||||||
|
public async Task<WebResult> Save(string filterId, FilterForm form, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = filterId is null
|
||||||
|
? await httpService.Post(APIs.Mastodon.POST_Filter, form, cancellationToken: cancellationToken)
|
||||||
|
: await httpService.Put(APIs.Mastodon.PUT_M_Filter(filterId), form, cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Filter>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> Delete(string filterId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Filter(filterId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds a post to the filter; Data is the FilterStatus.</summary>
|
||||||
|
public async Task<WebResult> AddStatus(string filterId, string statusId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_FilterStatus(filterId), new[] { new KeyValuePair<string, string>("status_id", statusId) },
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<FilterStatus>(cancellationToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> RemoveStatus(string filterStatusId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_FilterStatus(filterStatusId), cancellationToken: cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||||
|
|
||||||
|
return new WebResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using decePubClient.E2E.Support;
|
||||||
|
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Actions
|
||||||
|
{
|
||||||
|
// A filter in decePub: made on /filters with a word only one post has, which the home feed then folds behind the
|
||||||
|
// filter's title (warn) and leaves out once the filter hides; PrivaPub keeps what the page sent.
|
||||||
|
public sealed class FilterTests(Browser browser)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task A_filters_word_folds_then_hides_a_post_in_the_home_feed()
|
||||||
|
{
|
||||||
|
var persona = Pasture.Personas().First();
|
||||||
|
var word = $"zq{Guid.NewGuid():N}"[..10];
|
||||||
|
var title = $"e2e {word}";
|
||||||
|
foreach (var left in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>().StartsWith("e2e ")))
|
||||||
|
await PersonaApi.Call(persona, $"/api/v2/filters/{left!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||||
|
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = $"a post about {word}", ["visibility"] = "unlisted" }))!;
|
||||||
|
var uri = post["uri"]!.GetValue<string>();
|
||||||
|
await using var session = await SignIn.As(browser, persona);
|
||||||
|
var page = session.Page;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Results.Cell("client.action.filter", "privapub", persona.UserName, async () =>
|
||||||
|
{
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/filters");
|
||||||
|
await page.Locator("input[data-filter-new]").FillAsync(title);
|
||||||
|
await page.GetByRole(AriaRole.Button, new() { Name = "Make a filter" }).ClickAsync();
|
||||||
|
var article = page.Locator("article[data-filter-id]").Filter(new() { HasText = title });
|
||||||
|
await article.Locator("input[data-filter-keyword-new]").FillAsync(word);
|
||||||
|
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||||
|
var filterId = await article.GetAttributeAsync("data-filter-id");
|
||||||
|
Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["keywords"]?.AsArray()
|
||||||
|
.Any(k => k!["keyword"]!.GetValue<string>() == word) == true, 15), "PrivaPub never got the filter's word");
|
||||||
|
|
||||||
|
// warn: the card is folded behind the filter's title, and opens on request
|
||||||
|
// the feed opens on its default tab, Home, when no tab is remembered
|
||||||
|
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/");
|
||||||
|
var card = page.Locator($"article[data-uri='{uri}']");
|
||||||
|
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
await Assertions.Expect(card.Locator("[data-filtered]")).ToContainTextAsync(title);
|
||||||
|
await Assertions.Expect(card).Not.ToContainTextAsync($"a post about {word}");
|
||||||
|
await card.GetByRole(AriaRole.Button, new() { Name = "Show anyway" }).ClickAsync();
|
||||||
|
await Assertions.Expect(card).ToContainTextAsync($"a post about {word}");
|
||||||
|
|
||||||
|
// hide: the card is left out
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/filters");
|
||||||
|
article = page.Locator($"article[data-filter-id='{filterId}']");
|
||||||
|
await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
|
||||||
|
await article.Locator("select").First.SelectOptionAsync("Hide");
|
||||||
|
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||||
|
Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["filter_action"]?
|
||||||
|
.GetValue<string>() == "hide", 15), "PrivaPub never made the filter hide");
|
||||||
|
await page.GotoAsync($"{Pasture.Site}/");
|
||||||
|
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||||
|
await Assertions.Expect(page.Locator($"article[data-uri='{uri}']")).ToHaveCountAsync(0);
|
||||||
|
await session.AssertGuards();
|
||||||
|
}, page);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||||
|
foreach (var made in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>() == title))
|
||||||
|
await PersonaApi.Call(persona, $"/api/v2/filters/{made!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
using decePubClient.E2E.Support;
|
using decePubClient.E2E.Support;
|
||||||
@@ -13,19 +11,8 @@ namespace decePubClient.E2E.Actions
|
|||||||
// answer to the persona's token, so the page and the server agree on the list, its members and its posts.
|
// answer to the persona's token, so the page and the server agree on the list, its members and its posts.
|
||||||
public sealed class ListTests(Browser browser)
|
public sealed class ListTests(Browser browser)
|
||||||
{
|
{
|
||||||
static async Task<JsonNode> Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null)
|
static Task<JsonNode> Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null) =>
|
||||||
{
|
PersonaApi.Call(persona, path, method, body);
|
||||||
using var http = Pasture.Http();
|
|
||||||
using var request = new HttpRequestMessage(method ?? HttpMethod.Get, Pasture.Api + path);
|
|
||||||
if (body is not null)
|
|
||||||
request.Content = new StringContent(body.ToJsonString(), System.Text.Encoding.UTF8, "application/json");
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", persona.Token);
|
|
||||||
using var response = await http.SendAsync(request);
|
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
||||||
return null;
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
|
||||||
}
|
|
||||||
|
|
||||||
// an account the persona follows that has written something
|
// an account the persona follows that has written something
|
||||||
static async Task<JsonNode> Followed(Pasture.Persona persona) =>
|
static async Task<JsonNode> Followed(Pasture.Persona persona) =>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace decePubClient.E2E.Support
|
||||||
|
{
|
||||||
|
// PrivaPub's Mastodon API as a persona (its town token), for what a test sets up or checks behind the page
|
||||||
|
public static class PersonaApi
|
||||||
|
{
|
||||||
|
// the answer's JSON, or null for a 404
|
||||||
|
public static async Task<JsonNode> Call(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null)
|
||||||
|
{
|
||||||
|
using var http = Pasture.Http();
|
||||||
|
using var request = new HttpRequestMessage(method ?? HttpMethod.Get, Pasture.Api + path);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", persona.Token);
|
||||||
|
if (body is not null)
|
||||||
|
request.Content = new StringContent(body.ToJsonString(), System.Text.Encoding.UTF8, "application/json");
|
||||||
|
using var response = await http.SendAsync(request);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user