@page "/filters" @attribute [Authorize] @inherits LocalizableComponentBase @Localizer["Filters"] @* 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. *@

@Localizer["Filters"]

@Localizer["Make a filter"] @if (IsLoading) {

@Localizer["loading"]

} else if (filters.Count == 0) {

@Localizer["This persona has no filter yet."]

}
@foreach (var filter in filters) {
@filter.Title @ActionName(filter.FilterAction) @if (!filter.IsActive) { @Localizer["Expired"] } else if (filter.ExpiresAt is { } expires) { @Localizer["until {0}", expires.ToLocalTime().ToString(SUtility.dateTimeFormatExt)] } @Localizer["{0} words, {1} posts", filter.Keywords.Count, filter.Statuses.Count]
@if (open?.Id == filter.Id) {
@Localizer["Where"] @foreach (var context in Contexts) { }
@foreach (var action in Actions) { } @foreach (var (seconds, label) in Expiries) { }

@Localizer["Words"]

@foreach (var row in edit.Keywords.Where(k => !k.Removed)) {
@row.Keyword
}
@Localizer["Add"]
@if (filter.Statuses.Count > 0) {

@Localizer["Posts filtered one by one"]

@foreach (var item in filter.Statuses) {
@if (PostOf(item) is { } post) { @* alt="": the name is next to it. *@ @CustomEmojis.Text(post.Account?.Name, post.Account?.Emojis ?? []) @("@" + post.Account?.Acct) @(Excerpt(post) is { Length: > 0 } excerpt ? excerpt : Localizer["Media"]) } else if (readPostIds.Contains(item.StatusId)) { @Localizer["Post no longer available"] } else { }
}
}
@Localizer["Save"] @if (isConfirmingDelete) { @Localizer["Delete it for good"] @Localizer["Cancel"] } else { @Localizer["Delete"] }
}
}
@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 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 filteredPosts = new(); HashSet 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 Contexts { get; set; } = []; public FilterAction Action { get; set; } = FilterAction.Warn; public int ExpiresIn { get; set; } = KeepExpiry; public List 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; } } }