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:
thepraandClaude Opus 5.5 committed 2026-10-05 03:10:27 +02:00
1 parent e2491de16e
commit 9a752d9288
18 files changed
+1182 -240

No files matched your search

+6 -1
View File
@@ -212,7 +212,7 @@ supplies:
located and group posts go through `/clientapi/post/insert`), `AccountService` (profiles, relationships, follow,
block, mute, follow requests, `/api/v2/search`, whose handles and URLs PrivaPub resolves), `ListService` (the
persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`),
`MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`.
`FilterService` (the persona's v2 filters, their keywords and filtered posts), `MediaService` (uploads), `GroupService`, `InstanceService` and `ProvenanceService`.
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction or poll
vote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice.
@@ -222,6 +222,11 @@ supplies:
- **Lists** (`Pages/Lists.razor` at `/lists`): made, renamed, given a replies policy and made exclusive (their members
leave the home feed) there, filled from the accounts the persona follows; a followed account's profile has a Lists
menu too. A list is read as the feed's Lists tab (`FeedFilter.ListId`). Lists never leave PrivaPub.
- **Filters** (`Pages/Filters.razor` at `/filters`, linked from Settings): PrivaPub marks every status with the filters it
matches (`Status.Filtered`), whatever their context; a page tells each card where it is (`Content.FilterIn`: home and
lists, notifications, public feeds, threads around the opened post, profiles; none for favourites, bookmarks,
conversations and search), and the card leaves the post out (hide), folds it behind the filter's title (warn) or blurs
its media (blur).
- **Mastodon DTOs** are client-side, in `Models/Mastodon/` (snake_case). `/clientapi` DTOs come from
`PrivaPub.ClientModels`.
- **Where a post is** (`InstanceService.Locate`, `Models/FeedPost.cs`): its own place (Pixelfed's, `privapub.place`),
+248 -219
View File
@@ -5,247 +5,262 @@
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id,
which the e2e tests (tests/e2e) find it by. Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on
which the e2e tests (tests/e2e) find it by. The persona's filters apply where the page says the card is (FilterIn): a
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
data-post-id="@(Compact ? null : Post.Key)" data-lat="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Latitude))"
data-lng="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Longitude))"
data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())" data-cdn="@(Compact ? null : Post.Location?.CdnKey)"
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)" data-post-at="@(Compact ? null : Post.WrittenAt?.ToString("O"))"
data-uri="@(Compact ? null : Post.Status?.Uri)">
@if (!IsFilteredOut)
{
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
data-post-id="@(Compact ? null : Post.Key)" data-lat="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Latitude))"
data-lng="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Longitude))"
data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())" data-cdn="@(Compact ? null : Post.Location?.CdnKey)"
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)" data-post-at="@(Compact ? null : Post.WrittenAt?.ToString("O"))"
data-uri="@(Compact ? null : Post.Status?.Uri)">
@if (Author is { } author)
{
<div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
style="@HeaderBackground(author)">
<a class="block h-12 w-12 md:h-14 md:w-14" href="@($"account/{Uri.EscapeDataString(author.Id)}")" title="@author.Acct">
@* alt="": the author's name is next to it. *@
<img alt="" loading="lazy" class="h-12 w-12 md:h-14 md:w-14 object-cover rounded-full neo-raised neo-2xs bg-base-100"
src="@(author.Avatar ?? "imgs/icon-192.png")" />
</a>
</div>
}
<div class="flex flex-col gap-2 flex-1 py-3 pr-3 md:py-4 md:pr-4 min-w-0">
@if (Post.Notification is { } notification)
@if (Author is { } author)
{
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
<i aria-hidden="true" class="@NotificationIcon(notification.Type)"></i>
<span>@NotificationText(notification)</span>
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
</p>
}
else if (Post.BoostedBy is { } booster)
{
<p class="inline-flex flex-1 items-center gap-2 min-w-0 text-xs">
<i class="ion-md-repeat text-info" aria-hidden="true"></i>
<a class="font-bold truncate" href="@booster.Url" target="_blank" rel="noopener noreferrer" title="@booster.Acct">@booster.Name</a>
<span>@Localizer["boosted"]</span>
</p>
}
else if (Post.Conversation is { } conversation)
{
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
<i class="ion-md-mail @(conversation.Unread ? "text-primary" : null)" aria-hidden="true"></i>
<span class="truncate">@string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct))</span>
</p>
<div class="flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
style="@HeaderBackground(author)">
<a class="block h-12 w-12 md:h-14 md:w-14" href="@($"account/{Uri.EscapeDataString(author.Id)}")" title="@author.Acct">
@* alt="": the author's name is next to it. *@
<img alt="" loading="lazy" class="h-12 w-12 md:h-14 md:w-14 object-cover rounded-full neo-raised neo-2xs bg-base-100"
src="@(author.Avatar ?? "imgs/icon-192.png")" />
</a>
</div>
}
@if (Status is { } status)
{
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-xs md:text-sm">
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" OnOpen="ReadServer" Align="DTooltipAlign.Start" Class="min-w-0 flex-1">
<span class="inline-flex gap-2 min-w-0 max-w-full">
<b class="shrink truncate" title="@status.Account?.Name">@status.Account?.Name</b>
<span class="flex-1 min-w-6 opacity-50 truncate self-center text-xs">@("@" + status.Account?.Acct)</span>
</span>
</NerdTip>
<span class="flex-none inline-flex gap-2 items-center">
@if (Post.Location is { Source: LocationSource.Cdn } fronted)
{
<NerdTip Heading="@Localizer["The server is behind {0}, so its place is unknown.", fronted.Label]" Rows="CdnRows()" Pending="isReadingServer"
OnOpen="ReadServer" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
<i class="ion-md-cloud" aria-hidden="true"></i>
<span class="truncate">@fronted.Label</span>
</span>
</NerdTip>
}
else if (Post.Location is { } location)
{
<NerdTip Heading="@Localizer["Place"]" Rows="LocationRows()" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
<i class="@LocationIcon(location.Source)" aria-hidden="true"></i>
<span class="truncate">@(location.Label ?? Localizer["Near you"])</span>
</span>
</NerdTip>
}
<NerdTip Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" OnOpen="ReadProvenance"
Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<time datetime="@NerdRow.Time(status.CreatedAt)" class="opacity-80">@status.CreatedAt.GetPassedTime(Localizer)</time>
@if (status.EditedAt is not null)
{
<span class="opacity-60">*</span>
}
</NerdTip>
<i aria-hidden="true" class="@VisibilityIcon(status.Visibility) text-base" title="@VisibilityText(status.Visibility)"></i>
</span>
</header>
<div class="flex flex-col gap-2 flex-1 py-3 pr-3 md:py-4 md:pr-4 min-w-0">
@if (status.Privapub?.Title is { Length: > 0 } title)
@if (Post.Notification is { } notification)
{
<p class="text-sm md:text-base font-bold break-words">@title</p>
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
<i aria-hidden="true" class="@NotificationIcon(notification.Type)"></i>
<span>@NotificationText(notification)</span>
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
</p>
}
@if (status.SpoilerText is { Length: > 0 } spoiler)
else if (Post.BoostedBy is { } booster)
{
<DCollapse @bind-IsOpen="isSpoilerOpen" Icon="DCollapseIcon.Arrow" class="rounded-lg neo-xs" HeaderClass="text-sm py-2 min-h-0"
ContentClass="text-sm">
<Header>
<span class="inline-flex gap-2 items-center"><i class="ion-md-warning text-warning"></i>@spoiler</span>
</Header>
<ChildContent>
@Body(status)
</ChildContent>
</DCollapse>
<p class="inline-flex flex-1 items-center gap-2 min-w-0 text-xs">
<i class="ion-md-repeat text-info" aria-hidden="true"></i>
<a class="font-bold truncate" href="@booster.Url" target="_blank" rel="noopener noreferrer" title="@booster.Acct">@booster.Name</a>
<span>@Localizer["boosted"]</span>
</p>
}
else
else if (Post.Conversation is { } conversation)
{
@Body(status)
}
@if (Post.Located)
{
<p class="inline-flex gap-2 items-center text-xs opacity-70">
<i class="ion-md-locate"></i>
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
<i class="ion-md-mail @(conversation.Unread ? "text-primary" : null)" aria-hidden="true"></i>
<span class="truncate">@string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct))</span>
</p>
}
@if (!Post.Located && !Compact)
@if (Status is { } status)
{
<footer class="flex gap-2 mt-1 justify-between">
<div class="flex gap-2">
<DButton Size="DSize.Sm" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
<i aria-hidden="true" class="@(isReplying ? "ion-md-close-circle" : "ion-md-text") text-lg text-success"></i>
@if (status.RepliesCount > 0)
{
<span>@status.RepliesCount</span>
}
</DButton>
<DButton Size="DSize.Sm" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
OnClick="() => Act(status.Reblogged ? StatusAction.Unreblog : StatusAction.Reblog)" title="@Localizer["Boost"]">
<i aria-hidden="true" class="ion-md-repeat text-lg text-info"></i>
@if (status.ReblogsCount > 0)
{
<span>@status.ReblogsCount</span>
}
</DButton>
<DButton Size="DSize.Sm" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Favourited ? StatusAction.Unfavourite : StatusAction.Favourite)" title="@Localizer["Favourite"]">
<i aria-hidden="true" class="@(status.Favourited ? "ion-md-heart" : "ion-md-heart-empty") text-lg text-pink-400"></i>
@if (status.FavouritesCount > 0)
{
<span>@status.FavouritesCount</span>
}
</DButton>
<DButton Size="DSize.Sm" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
</DButton>
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true"
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" ContentClass="mb-2 flex gap-1 p-2 rounded-box"
title="@Localizer["React"]">
<Trigger><i aria-hidden="true" class="ion-md-happy text-lg"></i></Trigger>
<ChildContent>
@foreach (var emoji in QuickReactions)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)">@emoji</DButton>
}
</ChildContent>
</DDropdown>
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
</NerdTip>
</div>
<div class="flex gap-2">
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.End" CloseOnContentClick="true" Placement="DDropdownPlacement.Top"
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
ContentClass="mb-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
<Trigger>
<i aria-hidden="true" class="ion-md-more text-lg"></i>
</Trigger>
<ChildContent>
@if (status.Url is { Length: > 0 } url)
{
<a class="btn btn-sm justify-start whitespace-nowrap" href="@url" target="_blank" rel="noopener noreferrer">
<i aria-hidden="true" class="ion-md-open text-base"></i>
<span>@Localizer["Open original"]</span>
</a>
}
@if (!IsOwn)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
<i aria-hidden="true" class="ion-md-eye-off text-base text-warning"></i>
<span>@Localizer["Mute"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("block"))">
<i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i>
<span>@Localizer["Block"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="Delete">
<i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
<span>@Localizer["Delete"]</span>
</DButton>
}
</ChildContent>
</DDropdown>
@if (IncludeExpand)
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-xs md:text-sm">
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" OnOpen="ReadServer" Align="DTooltipAlign.Start" Class="min-w-0 flex-1">
<span class="inline-flex gap-2 min-w-0 max-w-full">
<b class="shrink truncate" title="@status.Account?.Name">@status.Account?.Name</b>
<span class="flex-1 min-w-6 opacity-50 truncate self-center text-xs">@("@" + status.Account?.Acct)</span>
</span>
</NerdTip>
<span class="flex-none inline-flex gap-2 items-center">
@if (Post.Location is { Source: LocationSource.Cdn } fronted)
{
<NavLink class="btn btn-sm" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]">
<i aria-hidden="true" class="ion-md-expand text-lg"></i>
</NavLink>
<NerdTip Heading="@Localizer["The server is behind {0}, so its place is unknown.", fronted.Label]" Rows="CdnRows()" Pending="isReadingServer"
OnOpen="ReadServer" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
<i class="ion-md-cloud" aria-hidden="true"></i>
<span class="truncate">@fronted.Label</span>
</span>
</NerdTip>
}
</div>
</footer>
else if (Post.Location is { } location)
{
<NerdTip Heading="@Localizer["Place"]" Rows="LocationRows()" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<span class="badge badge-ghost badge-sm gap-1 max-w-40">
<i class="@LocationIcon(location.Source)" aria-hidden="true"></i>
<span class="truncate">@(location.Label ?? Localizer["Near you"])</span>
</span>
</NerdTip>
}
<NerdTip Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" OnOpen="ReadProvenance"
Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<time datetime="@NerdRow.Time(status.CreatedAt)" class="opacity-80">@status.CreatedAt.GetPassedTime(Localizer)</time>
@if (status.EditedAt is not null)
{
<span class="opacity-60">*</span>
}
</NerdTip>
<i aria-hidden="true" class="@VisibilityIcon(status.Visibility) text-base" title="@VisibilityText(status.Visibility)"></i>
</span>
</header>
@if (isReplying)
@if (Warnings.Count > 0 && !showFiltered)
{
<Composer InReplyTo="status" OnPublished="Replied" />
<div class="flex flex-wrap items-center gap-2 text-sm rounded-lg neo-inset neo-2xs px-3 py-2" data-filtered>
<i class="ion-md-funnel opacity-70" aria-hidden="true"></i>
<span class="flex-1 min-w-0 break-words">@Localizer["Filtered: {0}", string.Join(", ", Warnings.Select(w => w.Filter.Title))]</span>
<DButton Size="DSize.Xs" OnClick="() => showFiltered = true">@Localizer["Show anyway"]</DButton>
</div>
}
else
{
@if (status.Privapub?.Title is { Length: > 0 } title)
{
<p class="text-sm md:text-base font-bold break-words">@title</p>
}
@if (status.SpoilerText is { Length: > 0 } spoiler)
{
<DCollapse @bind-IsOpen="isSpoilerOpen" Icon="DCollapseIcon.Arrow" class="rounded-lg neo-xs" HeaderClass="text-sm py-2 min-h-0"
ContentClass="text-sm">
<Header>
<span class="inline-flex gap-2 items-center"><i class="ion-md-warning text-warning"></i>@spoiler</span>
</Header>
<ChildContent>
@Body(status)
</ChildContent>
</DCollapse>
}
else
{
@Body(status)
}
@if (Post.Located)
{
<p class="inline-flex gap-2 items-center text-xs opacity-70">
<i class="ion-md-locate"></i>
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
</p>
}
@if (!Post.Located && !Compact)
{
<footer class="flex gap-2 mt-1 justify-between">
<div class="flex gap-2">
<DButton Size="DSize.Sm" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
<i aria-hidden="true" class="@(isReplying ? "ion-md-close-circle" : "ion-md-text") text-lg text-success"></i>
@if (status.RepliesCount > 0)
{
<span>@status.RepliesCount</span>
}
</DButton>
<DButton Size="DSize.Sm" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
OnClick="() => Act(status.Reblogged ? StatusAction.Unreblog : StatusAction.Reblog)" title="@Localizer["Boost"]">
<i aria-hidden="true" class="ion-md-repeat text-lg text-info"></i>
@if (status.ReblogsCount > 0)
{
<span>@status.ReblogsCount</span>
}
</DButton>
<DButton Size="DSize.Sm" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Favourited ? StatusAction.Unfavourite : StatusAction.Favourite)" title="@Localizer["Favourite"]">
<i aria-hidden="true" class="@(status.Favourited ? "ion-md-heart" : "ion-md-heart-empty") text-lg text-pink-400"></i>
@if (status.FavouritesCount > 0)
{
<span>@status.FavouritesCount</span>
}
</DButton>
<DButton Size="DSize.Sm" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
</DButton>
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true"
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" ContentClass="mb-2 flex gap-1 p-2 rounded-box"
title="@Localizer["React"]">
<Trigger><i aria-hidden="true" class="ion-md-happy text-lg"></i></Trigger>
<ChildContent>
@foreach (var emoji in QuickReactions)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)">@emoji</DButton>
}
</ChildContent>
</DDropdown>
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
</NerdTip>
</div>
<div class="flex gap-2">
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.End" CloseOnContentClick="true" Placement="DDropdownPlacement.Top"
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
ContentClass="mb-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
<Trigger>
<i aria-hidden="true" class="ion-md-more text-lg"></i>
</Trigger>
<ChildContent>
@if (status.Url is { Length: > 0 } url)
{
<a class="btn btn-sm justify-start whitespace-nowrap" href="@url" target="_blank" rel="noopener noreferrer">
<i aria-hidden="true" class="ion-md-open text-base"></i>
<span>@Localizer["Open original"]</span>
</a>
}
@if (!IsOwn)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
<i aria-hidden="true" class="ion-md-eye-off text-base text-warning"></i>
<span>@Localizer["Mute"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("block"))">
<i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i>
<span>@Localizer["Block"]</span>
</DButton>
}
else
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="Delete">
<i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
<span>@Localizer["Delete"]</span>
</DButton>
}
</ChildContent>
</DDropdown>
@if (IncludeExpand)
{
<NavLink class="btn btn-sm" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]">
<i aria-hidden="true" class="ion-md-expand text-lg"></i>
</NavLink>
}
</div>
</footer>
@if (isReplying)
{
<Composer InReplyTo="status" OnPublished="Replied" />
}
}
}
}
}
else if (Post.Notification?.Account is { } follower)
{
<p class="text-sm">
<a class="font-bold" href="@($"account/{Uri.EscapeDataString(follower.Id)}")">@follower.Name</a>
<span class="opacity-60">@("@" + follower.Acct)</span>
</p>
@if (Post.Notification.Type == "follow_request" && requestAnswer is null)
else if (Post.Notification?.Account is { } follower)
{
<div class="flex gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => AnswerRequest(follower, true)">
<i class="ion-md-checkmark"></i><span>@Localizer["Accept"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => AnswerRequest(follower, false)">
<i class="ion-md-close"></i><span>@Localizer["Refuse"]</span>
</DButton>
</div>
<p class="text-sm">
<a class="font-bold" href="@($"account/{Uri.EscapeDataString(follower.Id)}")">@follower.Name</a>
<span class="opacity-60">@("@" + follower.Acct)</span>
</p>
@if (Post.Notification.Type == "follow_request" && requestAnswer is null)
{
<div class="flex gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => AnswerRequest(follower, true)">
<i class="ion-md-checkmark"></i><span>@Localizer["Accept"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => AnswerRequest(follower, false)">
<i class="ion-md-close"></i><span>@Localizer["Refuse"]</span>
</DButton>
</div>
}
else if (requestAnswer is { } accepted)
{
<p class="text-xs opacity-70">@(accepted ? Localizer["Accepted."] : Localizer["Refused."])</p>
}
}
else if (requestAnswer is { } accepted)
{
<p class="text-xs opacity-70">@(accepted ? Localizer["Accepted."] : Localizer["Refused."])</p>
}
}
</div>
</div>
</article>
</article>
}
@code {
[Inject] StatusService StatusService { get; set; }
@@ -262,11 +277,15 @@
[Parameter] public bool IncludeExpand { get; set; } = true;
/// <summary>Without actions, e.g. a quoted post.</summary>
[Parameter] public bool Compact { get; set; }
/// <summary>Where the card is shown, for the persona's filters (Status.Filtered): a hide filter of that context leaves
/// it out, a warn filter folds it, a blur filter blurs its media. No filter applies without it.</summary>
[Parameter] public FilterContext? FilterIn { get; set; }
bool isReplying;
bool isActing;
bool isOptionsOpen;
bool isSpoilerOpen;
bool showFiltered;
bool isReadingProvenance;
bool isReadingServer;
readonly HashSet<string> revealedMedia = [];
@@ -277,6 +296,16 @@
InstanceLocation ServerGeo => Server?.Geo;
bool IsOwn => Post.IsOwnedBy(CascadingState.PersonaId);
List<FilterResult> Matched => FilterIn is { } where && Status is { } status
? [.. status.Filtered.Where(r => r.Filter is { IsActive: true } filter && filter.Context.Contains(where))]
: [];
bool IsFilteredOut => Matched.Any(r => r.Filter.FilterAction == FilterAction.Hide);
List<FilterResult> Warnings => [.. Matched.Where(r => r.Filter.FilterAction == FilterAction.Warn)];
bool IsBlurred => !showFiltered && Matched.Any(r => r.Filter.FilterAction == FilterAction.Blur);
protected override void OnInitialized() => IsLoading = false;
RenderFragment Body(Status status) => @<div class="flex flex-col gap-3">
@@ -291,11 +320,11 @@
@foreach (var media in status.MediaAttachments)
{
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full">
@if (status.Sensitive && !revealedMedia.Contains(media.Id))
@if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id))
{
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm"
@onclick="() => revealedMedia.Add(media.Id)">
<i class="ion-md-eye-off mr-2"></i>@Localizer["Sensitive media, show"]
<i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])
</button>
}
else
+4
View File
@@ -38,6 +38,10 @@ namespace decePubClient;
[JsonSerializable(typeof(MastodonList))]
[JsonSerializable(typeof(List<MastodonList>))]
[JsonSerializable(typeof(ListAccountsForm))]
[JsonSerializable(typeof(Filter))]
[JsonSerializable(typeof(List<Filter>))]
[JsonSerializable(typeof(FilterForm))]
[JsonSerializable(typeof(FilterStatus))]
public partial class MastodonJsonContext : JsonSerializerContext
{
}
+6
View File
@@ -70,6 +70,12 @@ public static class APIs
public static string M_ListAccounts(string listId) => $"api/v1/lists/{Uri.EscapeDataString(listId)}/accounts";
public static string GET_M_AccountLists(string accountId) => $"api/v1/accounts/{Uri.EscapeDataString(accountId)}/lists";
public static string GET_M_ListTimeline(string listId) => $"api/v1/timelines/list/{Uri.EscapeDataString(listId)}";
public const string GET_Filters = "api/v2/filters";
public const string POST_Filter = "api/v2/filters";
public static string PUT_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
public static string DELETE_M_Filter(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}";
public static string POST_M_FilterStatus(string filterId) => $"api/v2/filters/{Uri.EscapeDataString(filterId)}/statuses";
public static string DELETE_M_FilterStatus(string filterStatusId) => $"api/v2/filters/statuses/{Uri.EscapeDataString(filterStatusId)}";
}
public static class PrivaPub
+80
View File
@@ -0,0 +1,80 @@
using System.Text.Json.Serialization;
namespace decePubClient.Models.Mastodon;
/// <summary>A persona's filter (Mastodon's v2 filters): words and posts it would rather not see in some contexts, to be
/// warned about, hidden or blurred. Statuses carry the filters they match (Status.Filtered); the client applies the
/// context and the action.</summary>
public class Filter
{
public string Id { get; set; }
public string Title { get; set; }
public List<FilterContext> Context { get; set; } = [];
public DateTime? ExpiresAt { get; set; }
public FilterAction FilterAction { get; set; } = FilterAction.Warn;
public List<FilterKeyword> Keywords { get; set; } = [];
public List<FilterStatus> Statuses { get; set; } = [];
public bool IsActive => ExpiresAt is null || ExpiresAt > DateTime.UtcNow;
}
public class FilterKeyword
{
public string Id { get; set; }
public string Keyword { get; set; }
public bool WholeWord { get; set; }
}
public class FilterStatus
{
public string Id { get; set; }
public string StatusId { get; set; }
}
public class FilterResult
{
public Filter Filter { get; set; }
public List<string> KeywordMatches { get; set; } = [];
public List<string> StatusMatches { get; set; } = [];
}
/// <summary>Where a filter applies; a card is told which of these it is shown in.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<FilterContext>))]
public enum FilterContext
{
[JsonStringEnumMemberName("home")] Home,
[JsonStringEnumMemberName("notifications")] Notifications,
[JsonStringEnumMemberName("public")] Public,
[JsonStringEnumMemberName("thread")] Thread,
[JsonStringEnumMemberName("account")] Account
}
[JsonConverter(typeof(JsonStringEnumConverter<FilterAction>))]
public enum FilterAction
{
/// <summary>The post is folded behind the filter's title.</summary>
[JsonStringEnumMemberName("warn")] Warn,
/// <summary>The post is not shown at all.</summary>
[JsonStringEnumMemberName("hide")] Hide,
/// <summary>The post's media are blurred, as sensitive media are.</summary>
[JsonStringEnumMemberName("blur")] Blur
}
/// <summary>What making or changing a filter sends; ExpiresIn 0 means never.</summary>
public class FilterForm
{
public string Title { get; set; }
public List<FilterContext> Context { get; set; } = [];
public FilterAction FilterAction { get; set; } = FilterAction.Warn;
public int? ExpiresIn { get; set; }
public List<FilterKeywordForm> KeywordsAttributes { get; set; } = [];
}
public class FilterKeywordForm
{
public string Id { get; set; }
public string Keyword { get; set; }
public bool WholeWord { get; set; }
[JsonPropertyName("_destroy")]
public bool? Destroy { get; set; }
}
+2
View File
@@ -42,6 +42,8 @@ public class Status
public Quote Quote { get; set; }
/// <summary>Emoji reactions (PrivaPub sends Pleroma's and Misskey's forms as one list).</summary>
public List<EmojiReaction> EmojiReactions { get; set; } = [];
/// <summary>The persona's filters this post matches, whatever their context (the card picks those of where it is).</summary>
public List<FilterResult> Filtered { get; set; } = [];
public int QuotesCount { get; set; }
}
+4 -3
View File
@@ -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>
+399
View File
@@ -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
View File
@@ -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
View File
@@ -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)
{
+14
View File
@@ -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">
+1
View File
@@ -43,6 +43,7 @@ builder.Services.AddOptions()
.AddScoped<StatusService>()
.AddScoped<AccountService>()
.AddScoped<ListService>()
.AddScoped<FilterService>()
.AddScoped<PublicNoticeService>()
.AddScoped<MediaService>()
.AddScoped<GroupService>()
+117
View File
@@ -989,4 +989,121 @@
<data name="{0} removed from {1}." xml:space="preserve">
<value>{0} tolto da {1}.</value>
</data>
<data name="Filters" xml:space="preserve">
<value>Filtri</value>
</data>
<data name="A new filter&#x27;s title" xml:space="preserve">
<value>Il titolo di un nuovo filtro</value>
</data>
<data name="Make a filter" xml:space="preserve">
<value>Crea un filtro</value>
</data>
<data name="This persona has no filter yet." xml:space="preserve">
<value>Questa persona non ha ancora filtri.</value>
</data>
<data name="Expired" xml:space="preserve">
<value>Scaduto</value>
</data>
<data name="until {0}" xml:space="preserve">
<value>fino al {0}</value>
</data>
<data name="{0} words, {1} posts" xml:space="preserve">
<value>{0} parole, {1} post</value>
</data>
<data name="Where" xml:space="preserve">
<value>Dove</value>
</data>
<data name="How" xml:space="preserve">
<value>Come</value>
</data>
<data name="Expires" xml:space="preserve">
<value>Scadenza</value>
</data>
<data name="Words" xml:space="preserve">
<value>Parole</value>
</data>
<data name="Whole word" xml:space="preserve">
<value>Parola intera</value>
</data>
<data name="A word or a phrase" xml:space="preserve">
<value>Una parola o una frase</value>
</data>
<data name="Posts filtered one by one" xml:space="preserve">
<value>Post filtrati uno per uno</value>
</data>
<data name="Keep its expiry" xml:space="preserve">
<value>Mantieni la scadenza</value>
</data>
<data name="Never expires" xml:space="preserve">
<value>Non scade mai</value>
</data>
<data name="Expires in 30 minutes" xml:space="preserve">
<value>Scade tra 30 minuti</value>
</data>
<data name="Expires in 1 hour" xml:space="preserve">
<value>Scade tra 1 ora</value>
</data>
<data name="Expires in 12 hours" xml:space="preserve">
<value>Scade tra 12 ore</value>
</data>
<data name="Expires in 1 day" xml:space="preserve">
<value>Scade tra 1 giorno</value>
</data>
<data name="Expires in 1 week" xml:space="preserve">
<value>Scade tra 1 settimana</value>
</data>
<data name="Home and lists" xml:space="preserve">
<value>Home e liste</value>
</data>
<data name="Public feeds" xml:space="preserve">
<value>Feed pubblici</value>
</data>
<data name="Threads" xml:space="preserve">
<value>Discussioni</value>
</data>
<data name="Profiles" xml:space="preserve">
<value>Profili</value>
</data>
<data name="Hide" xml:space="preserve">
<value>Nascondi</value>
</data>
<data name="Blur" xml:space="preserve">
<value>Sfoca</value>
</data>
<data name="Warn" xml:space="preserve">
<value>Avvisa</value>
</data>
<data name="Leave the post out" xml:space="preserve">
<value>Togli il post</value>
</data>
<data name="Blur its media" xml:space="preserve">
<value>Sfoca i suoi media</value>
</data>
<data name="Fold the post behind the filter&#x27;s title" xml:space="preserve">
<value>Piega il post dietro il titolo del filtro</value>
</data>
<data name="Filter {0} made." xml:space="preserve">
<value>Filtro {0} creato.</value>
</data>
<data name="Filter {0} saved." xml:space="preserve">
<value>Filtro {0} salvato.</value>
</data>
<data name="Filter {0} deleted." xml:space="preserve">
<value>Filtro {0} eliminato.</value>
</data>
<data name="Filtered: {0}" xml:space="preserve">
<value>Filtrato: {0}</value>
</data>
<data name="Show anyway" xml:space="preserve">
<value>Mostra comunque</value>
</data>
<data name="Filtered media, show" xml:space="preserve">
<value>Media filtrati, mostra</value>
</data>
<data name="Words, phrases and posts this persona would rather not see, and where." xml:space="preserve">
<value>Parole, frasi e post che questa persona preferisce non vedere, e dove.</value>
</data>
<data name="Manage filters" xml:space="preserve">
<value>Gestisci i filtri</value>
</data>
</root>
+117
View File
@@ -989,4 +989,121 @@
<data name="{0} removed from {1}." xml:space="preserve">
<value>{0} removed from {1}.</value>
</data>
<data name="Filters" xml:space="preserve">
<value>Filters</value>
</data>
<data name="A new filter&#x27;s title" xml:space="preserve">
<value>A new filter's title</value>
</data>
<data name="Make a filter" xml:space="preserve">
<value>Make a filter</value>
</data>
<data name="This persona has no filter yet." xml:space="preserve">
<value>This persona has no filter yet.</value>
</data>
<data name="Expired" xml:space="preserve">
<value>Expired</value>
</data>
<data name="until {0}" xml:space="preserve">
<value>until {0}</value>
</data>
<data name="{0} words, {1} posts" xml:space="preserve">
<value>{0} words, {1} posts</value>
</data>
<data name="Where" xml:space="preserve">
<value>Where</value>
</data>
<data name="How" xml:space="preserve">
<value>How</value>
</data>
<data name="Expires" xml:space="preserve">
<value>Expires</value>
</data>
<data name="Words" xml:space="preserve">
<value>Words</value>
</data>
<data name="Whole word" xml:space="preserve">
<value>Whole word</value>
</data>
<data name="A word or a phrase" xml:space="preserve">
<value>A word or a phrase</value>
</data>
<data name="Posts filtered one by one" xml:space="preserve">
<value>Posts filtered one by one</value>
</data>
<data name="Keep its expiry" xml:space="preserve">
<value>Keep its expiry</value>
</data>
<data name="Never expires" xml:space="preserve">
<value>Never expires</value>
</data>
<data name="Expires in 30 minutes" xml:space="preserve">
<value>Expires in 30 minutes</value>
</data>
<data name="Expires in 1 hour" xml:space="preserve">
<value>Expires in 1 hour</value>
</data>
<data name="Expires in 12 hours" xml:space="preserve">
<value>Expires in 12 hours</value>
</data>
<data name="Expires in 1 day" xml:space="preserve">
<value>Expires in 1 day</value>
</data>
<data name="Expires in 1 week" xml:space="preserve">
<value>Expires in 1 week</value>
</data>
<data name="Home and lists" xml:space="preserve">
<value>Home and lists</value>
</data>
<data name="Public feeds" xml:space="preserve">
<value>Public feeds</value>
</data>
<data name="Threads" xml:space="preserve">
<value>Threads</value>
</data>
<data name="Profiles" xml:space="preserve">
<value>Profiles</value>
</data>
<data name="Hide" xml:space="preserve">
<value>Hide</value>
</data>
<data name="Blur" xml:space="preserve">
<value>Blur</value>
</data>
<data name="Warn" xml:space="preserve">
<value>Warn</value>
</data>
<data name="Leave the post out" xml:space="preserve">
<value>Leave the post out</value>
</data>
<data name="Blur its media" xml:space="preserve">
<value>Blur its media</value>
</data>
<data name="Fold the post behind the filter&#x27;s title" xml:space="preserve">
<value>Fold the post behind the filter's title</value>
</data>
<data name="Filter {0} made." xml:space="preserve">
<value>Filter {0} made.</value>
</data>
<data name="Filter {0} saved." xml:space="preserve">
<value>Filter {0} saved.</value>
</data>
<data name="Filter {0} deleted." xml:space="preserve">
<value>Filter {0} deleted.</value>
</data>
<data name="Filtered: {0}" xml:space="preserve">
<value>Filtered: {0}</value>
</data>
<data name="Show anyway" xml:space="preserve">
<value>Show anyway</value>
</data>
<data name="Filtered media, show" xml:space="preserve">
<value>Filtered media, show</value>
</data>
<data name="Words, phrases and posts this persona would rather not see, and where." xml:space="preserve">
<value>Words, phrases and posts this persona would rather not see, and where.</value>
</data>
<data name="Manage filters" xml:space="preserve">
<value>Manage filters</value>
</data>
</root>
+73
View File
@@ -0,0 +1,73 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// The current persona's filters (PrivaPub's /api/v2/filters): made, changed with their keywords, given posts, and
/// deleted. What they match comes on each status (Status.Filtered); the card applies it. Lists come back empty on
/// failure; nothing throws.
/// </summary>
public class FilterService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
public async Task<List<Filter>> All(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Filters, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<Filter>>(cancellationToken) ?? [];
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(FilterService)}.{nameof(All)}", cancellationToken);
return [];
}
/// <summary>Makes the filter when filterId is null, or changes it; Data is the filter as PrivaPub keeps it.</summary>
public async Task<WebResult> Save(string filterId, FilterForm form, CancellationToken cancellationToken = default)
{
using var response = filterId is null
? await httpService.Post(APIs.Mastodon.POST_Filter, form, cancellationToken: cancellationToken)
: await httpService.Put(APIs.Mastodon.PUT_M_Filter(filterId), form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Filter>(cancellationToken) };
}
public async Task<WebResult> Delete(string filterId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Filter(filterId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>Adds a post to the filter; Data is the FilterStatus.</summary>
public async Task<WebResult> AddStatus(string filterId, string statusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_FilterStatus(filterId), new[] { new KeyValuePair<string, string>("status_id", statusId) },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<FilterStatus>(cancellationToken) };
}
public async Task<WebResult> RemoveStatus(string filterStatusId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_FilterStatus(filterStatusId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// A filter in decePub: made on /filters with a word only one post has, which the home feed then folds behind the
// filter's title (warn) and leaves out once the filter hides; PrivaPub keeps what the page sent.
public sealed class FilterTests(Browser browser)
{
[Fact]
public async Task A_filters_word_folds_then_hides_a_post_in_the_home_feed()
{
var persona = Pasture.Personas().First();
var word = $"zq{Guid.NewGuid():N}"[..10];
var title = $"e2e {word}";
foreach (var left in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>().StartsWith("e2e ")))
await PersonaApi.Call(persona, $"/api/v2/filters/{left!["id"]!.GetValue<string>()}", HttpMethod.Delete);
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = $"a post about {word}", ["visibility"] = "unlisted" }))!;
var uri = post["uri"]!.GetValue<string>();
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
try
{
await Results.Cell("client.action.filter", "privapub", persona.UserName, async () =>
{
await page.GotoAsync($"{Pasture.Site}/filters");
await page.Locator("input[data-filter-new]").FillAsync(title);
await page.GetByRole(AriaRole.Button, new() { Name = "Make a filter" }).ClickAsync();
var article = page.Locator("article[data-filter-id]").Filter(new() { HasText = title });
await article.Locator("input[data-filter-keyword-new]").FillAsync(word);
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
var filterId = await article.GetAttributeAsync("data-filter-id");
Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["keywords"]?.AsArray()
.Any(k => k!["keyword"]!.GetValue<string>() == word) == true, 15), "PrivaPub never got the filter's word");
// warn: the card is folded behind the filter's title, and opens on request
// the feed opens on its default tab, Home, when no tab is remembered
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
await page.GotoAsync($"{Pasture.Site}/");
var card = page.Locator($"article[data-uri='{uri}']");
await card.WaitForAsync(new() { Timeout = 30000 });
await Assertions.Expect(card.Locator("[data-filtered]")).ToContainTextAsync(title);
await Assertions.Expect(card).Not.ToContainTextAsync($"a post about {word}");
await card.GetByRole(AriaRole.Button, new() { Name = "Show anyway" }).ClickAsync();
await Assertions.Expect(card).ToContainTextAsync($"a post about {word}");
// hide: the card is left out
await page.GotoAsync($"{Pasture.Site}/filters");
article = page.Locator($"article[data-filter-id='{filterId}']");
await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
await article.Locator("select").First.SelectOptionAsync("Hide");
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["filter_action"]?
.GetValue<string>() == "hide", 15), "PrivaPub never made the filter hide");
await page.GotoAsync($"{Pasture.Site}/");
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
await Assertions.Expect(page.Locator($"article[data-uri='{uri}']")).ToHaveCountAsync(0);
await session.AssertGuards();
}, page);
}
finally
{
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
foreach (var made in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>() == title))
await PersonaApi.Call(persona, $"/api/v2/filters/{made!["id"]!.GetValue<string>()}", HttpMethod.Delete);
}
}
}
}
+2 -15
View File
@@ -1,5 +1,3 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
@@ -13,19 +11,8 @@ namespace decePubClient.E2E.Actions
// answer to the persona's token, so the page and the server agree on the list, its members and its posts.
public sealed class ListTests(Browser browser)
{
static async Task<JsonNode> Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null)
{
using var http = Pasture.Http();
using var request = new HttpRequestMessage(method ?? HttpMethod.Get, Pasture.Api + path);
if (body is not null)
request.Content = new StringContent(body.ToJsonString(), System.Text.Encoding.UTF8, "application/json");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", persona.Token);
using var response = await http.SendAsync(request);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
}
static Task<JsonNode> Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null) =>
PersonaApi.Call(persona, path, method, body);
// an account the persona follows that has written something
static async Task<JsonNode> Followed(Pasture.Persona persona) =>
+25
View File
@@ -0,0 +1,25 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace decePubClient.E2E.Support
{
// PrivaPub's Mastodon API as a persona (its town token), for what a test sets up or checks behind the page
public static class PersonaApi
{
// the answer's JSON, or null for a 404
public static async Task<JsonNode> Call(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null)
{
using var http = Pasture.Http();
using var request = new HttpRequestMessage(method ?? HttpMethod.Get, Pasture.Api + path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", persona.Token);
if (body is not null)
request.Content = new StringContent(body.ToJsonString(), System.Text.Encoding.UTF8, "application/json");
using var response = await http.SendAsync(request);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
}
}
}