From 9a752d9288c36094bbf3e840097088bf73e18eb1 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 03:10:27 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 7 +- Components/Content.razor | 467 ++++++++++++++++--------------- MastodonJsonContext.cs | 4 + Models/APIs.cs | 6 + Models/Mastodon/Filter.cs | 80 ++++++ Models/Mastodon/Status.cs | 2 + Pages/ExpandMessage.razor | 7 +- Pages/Filters.razor | 399 ++++++++++++++++++++++++++ Pages/Index.razor | 12 +- Pages/Profile.razor | 2 +- Pages/Settings.razor | 14 + Program.cs | 1 + Resources/AllStrings.it.resx | 117 ++++++++ Resources/AllStrings.resx | 117 ++++++++ Services/FilterService.cs | 73 +++++ tests/e2e/Actions/FilterTests.cs | 72 +++++ tests/e2e/Actions/ListTests.cs | 17 +- tests/e2e/Support/PersonaApi.cs | 25 ++ 18 files changed, 1182 insertions(+), 240 deletions(-) create mode 100644 Models/Mastodon/Filter.cs create mode 100644 Pages/Filters.razor create mode 100644 Services/FilterService.cs create mode 100644 tests/e2e/Actions/FilterTests.cs create mode 100644 tests/e2e/Support/PersonaApi.cs diff --git a/CLAUDE.md b/CLAUDE.md index 0cfaf3e..6067f6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,7 @@ supplies: 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 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 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. @@ -222,6 +222,11 @@ supplies: - **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 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 `PrivaPub.ClientModels`. - **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`), diff --git a/Components/Content.razor b/Components/Content.razor index 9dd7dc0..31f1d12 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -5,247 +5,262 @@ 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 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. *@ -
+@if (!IsFilteredOut) +{ +
- @if (Author is { } author) - { - - } - -
- - @if (Post.Notification is { } notification) + @if (Author is { } author) { -

- - @NotificationText(notification) - @notification.CreatedAt.GetPassedTime(Localizer) -

- } - else if (Post.BoostedBy is { } booster) - { -

- - @booster.Name - @Localizer["boosted"] -

- } - else if (Post.Conversation is { } conversation) - { -

- - @string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct)) -

+ } - @if (Status is { } status) - { -
- - - @status.Account?.Name - @("@" + status.Account?.Acct) - - - - @if (Post.Location is { Source: LocationSource.Cdn } fronted) - { - - - - @fronted.Label - - - } - else if (Post.Location is { } location) - { - - - - @(location.Label ?? Localizer["Near you"]) - - - } - - - @if (status.EditedAt is not null) - { - * - } - - - -
+
- @if (status.Privapub?.Title is { Length: > 0 } title) + @if (Post.Notification is { } notification) { -

@title

+

+ + @NotificationText(notification) + @notification.CreatedAt.GetPassedTime(Localizer) +

} - - @if (status.SpoilerText is { Length: > 0 } spoiler) + else if (Post.BoostedBy is { } booster) { - -
- @spoiler -
- - @Body(status) - -
+

+ + @booster.Name + @Localizer["boosted"] +

} - else + else if (Post.Conversation is { } conversation) { - @Body(status) - } - - @if (Post.Located) - { -

- - @Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)] +

+ + @string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct))

} - @if (!Post.Located && !Compact) + @if (Status is { } status) { -
-
- - - @if (status.RepliesCount > 0) - { - @status.RepliesCount - } - - - - @if (status.ReblogsCount > 0) - { - @status.ReblogsCount - } - - - - @if (status.FavouritesCount > 0) - { - @status.FavouritesCount - } - - - - - - - - @foreach (var emoji in QuickReactions) - { - @emoji - } - - - - - -
- -
- - - - - - @if (status.Url is { Length: > 0 } url) - { - - - @Localizer["Open original"] - - } - @if (!IsOwn) - { - - - @Localizer["Mute"] - - - - @Localizer["Block"] - - } - else - { - - - @Localizer["Delete"] - - } - - - @if (IncludeExpand) +
+ + + @status.Account?.Name + @("@" + status.Account?.Acct) + + + + @if (Post.Location is { Source: LocationSource.Cdn } fronted) { - - - + + + + @fronted.Label + + } -
-
+ else if (Post.Location is { } location) + { + + + + @(location.Label ?? Localizer["Near you"]) + + + } + + + @if (status.EditedAt is not null) + { + * + } + + + + - @if (isReplying) + @if (Warnings.Count > 0 && !showFiltered) { - +
+ + @Localizer["Filtered: {0}", string.Join(", ", Warnings.Select(w => w.Filter.Title))] + @Localizer["Show anyway"] +
+ } + else + { + @if (status.Privapub?.Title is { Length: > 0 } title) + { +

@title

+ } + + @if (status.SpoilerText is { Length: > 0 } spoiler) + { + +
+ @spoiler +
+ + @Body(status) + +
+ } + else + { + @Body(status) + } + + @if (Post.Located) + { +

+ + @Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)] +

+ } + + @if (!Post.Located && !Compact) + { +
+
+ + + @if (status.RepliesCount > 0) + { + @status.RepliesCount + } + + + + @if (status.ReblogsCount > 0) + { + @status.ReblogsCount + } + + + + @if (status.FavouritesCount > 0) + { + @status.FavouritesCount + } + + + + + + + + @foreach (var emoji in QuickReactions) + { + @emoji + } + + + + + +
+ +
+ + + + + + @if (status.Url is { Length: > 0 } url) + { + + + @Localizer["Open original"] + + } + @if (!IsOwn) + { + + + @Localizer["Mute"] + + + + @Localizer["Block"] + + } + else + { + + + @Localizer["Delete"] + + } + + + @if (IncludeExpand) + { + + + + } +
+
+ + @if (isReplying) + { + + } + } } } - } - else if (Post.Notification?.Account is { } follower) - { -

- @follower.Name - @("@" + follower.Acct) -

- @if (Post.Notification.Type == "follow_request" && requestAnswer is null) + else if (Post.Notification?.Account is { } follower) { -
- - @Localizer["Accept"] - - - @Localizer["Refuse"] - -
+

+ @follower.Name + @("@" + follower.Acct) +

+ @if (Post.Notification.Type == "follow_request" && requestAnswer is null) + { +
+ + @Localizer["Accept"] + + + @Localizer["Refuse"] + +
+ } + else if (requestAnswer is { } accepted) + { +

@(accepted ? Localizer["Accepted."] : Localizer["Refused."])

+ } } - else if (requestAnswer is { } accepted) - { -

@(accepted ? Localizer["Accepted."] : Localizer["Refused."])

- } - } -
+
-
+
+} @code { [Inject] StatusService StatusService { get; set; } @@ -262,11 +277,15 @@ [Parameter] public bool IncludeExpand { get; set; } = true; /// Without actions, e.g. a quoted post. [Parameter] public bool Compact { get; set; } + /// 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. + [Parameter] public FilterContext? FilterIn { get; set; } bool isReplying; bool isActing; bool isOptionsOpen; bool isSpoilerOpen; + bool showFiltered; bool isReadingProvenance; bool isReadingServer; readonly HashSet revealedMedia = []; @@ -277,6 +296,16 @@ InstanceLocation ServerGeo => Server?.Geo; bool IsOwn => Post.IsOwnedBy(CascadingState.PersonaId); + List 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 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; RenderFragment Body(Status status) => @
@@ -291,11 +320,11 @@ @foreach (var media in status.MediaAttachments) { - @if (status.Sensitive && !revealedMedia.Contains(media.Id)) + @if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id)) { } else diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index c957be5..5480e4f 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -38,6 +38,10 @@ namespace decePubClient; [JsonSerializable(typeof(MastodonList))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ListAccountsForm))] +[JsonSerializable(typeof(Filter))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(FilterForm))] +[JsonSerializable(typeof(FilterStatus))] public partial class MastodonJsonContext : JsonSerializerContext { } diff --git a/Models/APIs.cs b/Models/APIs.cs index 11c3cff..08b460d 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -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 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 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 diff --git a/Models/Mastodon/Filter.cs b/Models/Mastodon/Filter.cs new file mode 100644 index 0000000..35139fe --- /dev/null +++ b/Models/Mastodon/Filter.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Serialization; + +namespace decePubClient.Models.Mastodon; + +/// 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. +public class Filter +{ + public string Id { get; set; } + public string Title { get; set; } + public List Context { get; set; } = []; + public DateTime? ExpiresAt { get; set; } + public FilterAction FilterAction { get; set; } = FilterAction.Warn; + public List Keywords { get; set; } = []; + public List 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 KeywordMatches { get; set; } = []; + public List StatusMatches { get; set; } = []; +} + +/// Where a filter applies; a card is told which of these it is shown in. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FilterContext +{ + [JsonStringEnumMemberName("home")] Home, + [JsonStringEnumMemberName("notifications")] Notifications, + [JsonStringEnumMemberName("public")] Public, + [JsonStringEnumMemberName("thread")] Thread, + [JsonStringEnumMemberName("account")] Account +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FilterAction +{ + /// The post is folded behind the filter's title. + [JsonStringEnumMemberName("warn")] Warn, + /// The post is not shown at all. + [JsonStringEnumMemberName("hide")] Hide, + /// The post's media are blurred, as sensitive media are. + [JsonStringEnumMemberName("blur")] Blur +} + +/// What making or changing a filter sends; ExpiresIn 0 means never. +public class FilterForm +{ + public string Title { get; set; } + public List Context { get; set; } = []; + public FilterAction FilterAction { get; set; } = FilterAction.Warn; + public int? ExpiresIn { get; set; } + public List 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; } +} diff --git a/Models/Mastodon/Status.cs b/Models/Mastodon/Status.cs index 963175a..3ce85b4 100644 --- a/Models/Mastodon/Status.cs +++ b/Models/Mastodon/Status.cs @@ -42,6 +42,8 @@ public class Status public Quote Quote { get; set; } /// Emoji reactions (PrivaPub sends Pleroma's and Misskey's forms as one list). public List EmojiReactions { get; set; } = []; + /// The persona's filters this post matches, whatever their context (the card picks those of where it is). + public List Filtered { get; set; } = []; public int QuotesCount { get; set; } } diff --git a/Pages/ExpandMessage.razor b/Pages/ExpandMessage.razor index 9188795..c41c80a 100644 --- a/Pages/ExpandMessage.razor +++ b/Pages/ExpandMessage.razor @@ -4,7 +4,8 @@ @Localizer["Thread"] @* 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. *@
@@ -32,14 +33,14 @@ { @foreach (var post in ancestors) { - + } @foreach (var post in descendants) { - + } }
diff --git a/Pages/Filters.razor b/Pages/Filters.razor new file mode 100644 index 0000000..59c32a6 --- /dev/null +++ b/Pages/Filters.razor @@ -0,0 +1,399 @@ +@page "/filters" +@attribute [Authorize] +@inherits LocalizableComponentBase +@Localizer["Filters"] + +@* 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. *@ +
+
+ +
+ +

@Localizer["Filters"]

+
+ + + + + + + @Localizer["Make a filter"] + + + + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (filters.Count == 0) + { +

@Localizer["This persona has no filter yet."]

+ } + +
+ @foreach (var filter in filters) + { +
+
+ @filter.Title + @ActionName(filter.FilterAction) + @if (!filter.IsActive) + { + @Localizer["Expired"] + } + else if (filter.ExpiresAt is { } expires) + { + @Localizer["until {0}", expires.ToLocalTime().ToString(SUtility.dateTimeFormatExt)] + } + @Localizer["{0} words, {1} posts", filter.Keywords.Count, filter.Statuses.Count] + + + +
+ + @if (open?.Id == filter.Id) + { + + + +
+ @Localizer["Where"] + @foreach (var context in Contexts) + { + + } +
+ +
+ + + + @foreach (var action in Actions) + { + + } + + + + + + @foreach (var (seconds, label) in Expiries) + { + + } + + +
+ +

@Localizer["Words"]

+
+ @foreach (var row in edit.Keywords.Where(k => !k.Removed)) + { +
+ @row.Keyword + + + + +
+ } +
+ + + @Localizer["Add"] + +
+
+ + @if (filter.Statuses.Count > 0) + { +

@Localizer["Posts filtered one by one"]

+
+ @foreach (var item in filter.Statuses) + { +
+ @item.StatusId + + + +
+ } +
+ } + +
+ + @Localizer["Save"] + + @if (isConfirmingDelete) + { + + @Localizer["Delete it for good"] + + @Localizer["Cancel"] + } + else + { + + @Localizer["Delete"] + + } +
+
+ } +
+ } +
+
+
+ +@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 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 Contexts { get; set; } = []; + public FilterAction Action { get; set; } = FilterAction.Warn; + public int ExpiresIn { get; set; } = KeepExpiry; + public List 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; } + } +} diff --git a/Pages/Index.razor b/Pages/Index.razor index 61f4484..11fef0a 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -74,7 +74,7 @@ @foreach (var post in posts) { - + }
@@ -175,6 +175,16 @@ _ = PollNewer(pageCancellation.Token); } + /// 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. + 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, Filter.Tag, position?.Latitude, position?.Longitude, Filter.ListId); diff --git a/Pages/Profile.razor b/Pages/Profile.razor index bcca77a..a971d66 100644 --- a/Pages/Profile.razor +++ b/Pages/Profile.razor @@ -141,7 +141,7 @@
@foreach (var post in posts) { - + } @if (posts.Count == 0 && !isLoadingMore) { diff --git a/Pages/Settings.razor b/Pages/Settings.razor index 83444ea..7568881 100644 --- a/Pages/Settings.razor +++ b/Pages/Settings.razor @@ -46,6 +46,20 @@ + +
+

+ @Localizer["Filters"] +

+
+ +
+

@Localizer["Words, phrases and posts this persona would rather not see, and where."]

+ @Localizer["Manage filters"] +
+
+
+

diff --git a/Program.cs b/Program.cs index 5782c4a..49881fd 100644 --- a/Program.cs +++ b/Program.cs @@ -43,6 +43,7 @@ builder.Services.AddOptions() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 3c0eebf..b6ac6c6 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -989,4 +989,121 @@ {0} tolto da {1}. + + Filtri + + + Il titolo di un nuovo filtro + + + Crea un filtro + + + Questa persona non ha ancora filtri. + + + Scaduto + + + fino al {0} + + + {0} parole, {1} post + + + Dove + + + Come + + + Scadenza + + + Parole + + + Parola intera + + + Una parola o una frase + + + Post filtrati uno per uno + + + Mantieni la scadenza + + + Non scade mai + + + Scade tra 30 minuti + + + Scade tra 1 ora + + + Scade tra 12 ore + + + Scade tra 1 giorno + + + Scade tra 1 settimana + + + Home e liste + + + Feed pubblici + + + Discussioni + + + Profili + + + Nascondi + + + Sfoca + + + Avvisa + + + Togli il post + + + Sfoca i suoi media + + + Piega il post dietro il titolo del filtro + + + Filtro {0} creato. + + + Filtro {0} salvato. + + + Filtro {0} eliminato. + + + Filtrato: {0} + + + Mostra comunque + + + Media filtrati, mostra + + + Parole, frasi e post che questa persona preferisce non vedere, e dove. + + + Gestisci i filtri + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index 1399e16..87e2cfe 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -989,4 +989,121 @@ {0} removed from {1}. + + Filters + + + A new filter's title + + + Make a filter + + + This persona has no filter yet. + + + Expired + + + until {0} + + + {0} words, {1} posts + + + Where + + + How + + + Expires + + + Words + + + Whole word + + + A word or a phrase + + + Posts filtered one by one + + + Keep its expiry + + + Never expires + + + Expires in 30 minutes + + + Expires in 1 hour + + + Expires in 12 hours + + + Expires in 1 day + + + Expires in 1 week + + + Home and lists + + + Public feeds + + + Threads + + + Profiles + + + Hide + + + Blur + + + Warn + + + Leave the post out + + + Blur its media + + + Fold the post behind the filter's title + + + Filter {0} made. + + + Filter {0} saved. + + + Filter {0} deleted. + + + Filtered: {0} + + + Show anyway + + + Filtered media, show + + + Words, phrases and posts this persona would rather not see, and where. + + + Manage filters + diff --git a/Services/FilterService.cs b/Services/FilterService.cs new file mode 100644 index 0000000..1b0ad5c --- /dev/null +++ b/Services/FilterService.cs @@ -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 +{ + ///

+ /// 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. + /// + public class FilterService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) + { + public async Task> All(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_Filters, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(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 []; + } + + /// Makes the filter when filterId is null, or changes it; Data is the filter as PrivaPub keeps it. + public async Task 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(cancellationToken) }; + } + + public async Task 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(); + } + + /// Adds a post to the filter; Data is the FilterStatus. + public async Task AddStatus(string filterId, string statusId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_M_FilterStatus(filterId), new[] { new KeyValuePair("status_id", statusId) }, + cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; + } + + public async Task 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(); + } + } +} diff --git a/tests/e2e/Actions/FilterTests.cs b/tests/e2e/Actions/FilterTests.cs new file mode 100644 index 0000000..e89863d --- /dev/null +++ b/tests/e2e/Actions/FilterTests.cs @@ -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().StartsWith("e2e "))) + await PersonaApi.Call(persona, $"/api/v2/filters/{left!["id"]!.GetValue()}", 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(); + 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() == 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() == "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()}", HttpMethod.Delete); + foreach (var made in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue() == title)) + await PersonaApi.Call(persona, $"/api/v2/filters/{made!["id"]!.GetValue()}", HttpMethod.Delete); + } + } + } +} diff --git a/tests/e2e/Actions/ListTests.cs b/tests/e2e/Actions/ListTests.cs index 323fbb7..88950fd 100644 --- a/tests/e2e/Actions/ListTests.cs +++ b/tests/e2e/Actions/ListTests.cs @@ -1,5 +1,3 @@ -using System.Net; -using System.Net.Http.Headers; using System.Text.Json.Nodes; 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. public sealed class ListTests(Browser browser) { - static async Task Api(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); - 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()); - } + static Task Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null) => + PersonaApi.Call(persona, path, method, body); // an account the persona follows that has written something static async Task Followed(Pasture.Persona persona) => diff --git a/tests/e2e/Support/PersonaApi.cs b/tests/e2e/Support/PersonaApi.cs new file mode 100644 index 0000000..36eb86f --- /dev/null +++ b/tests/e2e/Support/PersonaApi.cs @@ -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 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()); + } + } +}