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());
+ }
+ }
+}