Build / Build (push) Successful in 1m8s
Follow requests answered on the persona's own profile and from the Notifications tab; its followers and the accounts it follows listed there, a follower removed (asked first). Downvotes on threadiverse posts, after the public-vote notice. A notification dismissed from its line. "Filter this post" from a card's menu, and the Filters page showing the posts a filter holds. Who may quote the persona's own posts, and revoking a quote of one. Statistics: a server in detail with "describe again", the ledger's events, the server's own days. The composer deletes an upload taken out before posting. MediaType.Documents goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
455 lines
16 KiB
Plaintext
455 lines
16 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), and a card's "Filter this post" adds a post to one. The posts a filter holds are read by id when it
|
|
opens (/api/v1/statuses?id[]=) and show their author and words, linking their thread. 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-2">
|
|
@foreach (var item in filter.Statuses)
|
|
{
|
|
<div class="flex items-center gap-2 text-sm" data-filter-status="@item.StatusId">
|
|
@if (PostOf(item) is { } post)
|
|
{
|
|
<a class="flex flex-1 items-center gap-2 min-w-0 rounded-lg p-2 neo-inset neo-2xs pointer-coarse:min-h-11"
|
|
href="@($"expand/{Uri.EscapeDataString(post.Id)}")">
|
|
@* alt="": the name is next to it. *@
|
|
<img alt="" class="h-8 w-8 flex-none object-cover rounded-full" src="@(post.Account?.Avatar ?? "imgs/icon-192.png")" />
|
|
<span class="flex flex-col min-w-0">
|
|
<span class="truncate text-xs">
|
|
<b>@CustomEmojis.Text(post.Account?.Name, post.Account?.Emojis ?? [])</b>
|
|
<span class="opacity-60">@("@" + post.Account?.Acct)</span>
|
|
</span>
|
|
<span class="truncate opacity-80">@(Excerpt(post) is { Length: > 0 } excerpt ? excerpt : Localizer["Media"])</span>
|
|
</span>
|
|
</a>
|
|
}
|
|
else if (readPostIds.Contains(item.StatusId))
|
|
{
|
|
<span class="flex-1 min-w-0 opacity-70 italic" data-filter-status-missing>@Localizer["Post no longer available"]</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="flex-1 min-w-0"><DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" /></span>
|
|
}
|
|
<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; }
|
|
[Inject] TimelineService TimelineService { get; set; }
|
|
|
|
List<Filter> filters = [];
|
|
TitleForm draft = new();
|
|
FilterEdit edit = new();
|
|
Filter open;
|
|
string loadedPersonaId;
|
|
bool isSaving;
|
|
bool isConfirmingDelete;
|
|
// the posts the filters hold, read by id when a filter opens; an id read but missing is a post no longer available
|
|
Dictionary<string, Status> filteredPosts = new();
|
|
HashSet<string> readPostIds = [];
|
|
|
|
(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;
|
|
filteredPosts = new();
|
|
readPostIds = [];
|
|
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]);
|
|
await Toggle(filter);
|
|
}
|
|
|
|
async Task 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 })]
|
|
};
|
|
await ReadPosts(filter);
|
|
}
|
|
|
|
// its posts not read yet, 20 a request (TimelineService.Read); the ones that are gone stay missing
|
|
async Task ReadPosts(Filter filter)
|
|
{
|
|
var ids = filter.Statuses.Select(s => s.StatusId).Where(id => id is not null && !readPostIds.Contains(id)).Distinct().ToList();
|
|
if (ids.Count == 0)
|
|
return;
|
|
foreach (var (id, status) in await TimelineService.Read(ids))
|
|
filteredPosts[id] = status;
|
|
readPostIds.UnionWith(ids);
|
|
}
|
|
|
|
Status PostOf(FilterStatus item) => item.StatusId is { } id ? filteredPosts.GetValueOrDefault(id) : null;
|
|
|
|
// what the post says, as plain text: its content warning when it has one, else the start of its words
|
|
static string Excerpt(Status status)
|
|
{
|
|
var html = status.SpoilerText is { Length: > 0 } warning ? warning : status.Content;
|
|
if (string.IsNullOrEmpty(html))
|
|
return null;
|
|
var text = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ");
|
|
text = System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ")).Trim();
|
|
return text.Length <= 160 ? text : text[..160].TrimEnd() + "…";
|
|
}
|
|
|
|
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; }
|
|
}
|
|
}
|