399 lines
14 KiB
Plaintext
399 lines
14 KiB
Plaintext
@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; }
|
||
|
|
}
|
||
|
|
}
|