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:
thepraandClaude Opus 5.5 committed 2026-10-05 03:10:27 +02:00
1 parent e2491de16e
commit 9a752d9288
18 files changed
+1182 -240

No files matched your search

+4 -3
View File
@@ -4,7 +4,8 @@
<Title>@Localizer["Thread"]</Title>
@* 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">
<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)
{
<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" />
@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>
+399
View File
@@ -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
View File
@@ -74,7 +74,7 @@
@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">
@@ -175,6 +175,16 @@
_ = 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,
Filter.Tag, position?.Latitude, position?.Longitude, Filter.ListId);
+1 -1
View File
@@ -141,7 +141,7 @@
<div class="flex flex-col gap-4">
@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)
{
+14
View File
@@ -46,6 +46,20 @@
</ChildContent>
</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">
<Header>
<p class="inline-flex items-center gap-2">