Files
decePubClient/Pages/Filters.razor
T

399 lines
14 KiB
Plaintext
Raw Normal View History

@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; }
}
}