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
+1182
-240
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
<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
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user