Skip to content

Feature Request: Personal content filters (mute tags and keywords, with wildcards) #940

Description

@shaedrich

TL;DR Grow this from a tag-only deny list into a private, viewer-only content filter: mute a tag or a keyword/phrase (with * wildcards), and hide matching posts from your feed, your recommendations, and (later) search. Silent and one-directional like per-follow mute, but topic-level instead of person-level.

Motivation

Muting a whole tag is blunt. Someone who never wants crypto spam should not have to hunt down every tag it hides behind (#crypto, #bitcoin, #web3), and the stuff they want gone is often in the post text, not a tag at all. So the filter should also match words in the post body, with wildcards, and treat a muted tag as just one kind of entry in the same list.

Where

  • Feed: Vutuv.Posts.feed_page/2 (+ feed_post_items/3, feed_repost_items/3). Posts already carry tags via post_tags (Vutuv.Posts.PostTag, has_many :tags, through:), and the feed already drops muted follows (follows.muted) and blocks. This is the same shape one level up.
  • Recommendations: "Who to follow" (Vutuv.Social.most_followed_users/1 / Vutuv.Social.PopularUsers) and tag suggestions (Vutuv.Tags).
  • Search: VutuvWeb.SearchLive + Vutuv.Search (today has tag: / city: / @handle operators, no negative ones yet).
  • Siblings to reuse, not reinvent: per-follow mute (follows.muted, <.mute_button>, the post ⋯-menu "Mute @handle"), block (Vutuv.Social), #hashtag / honor tags (Vutuv.Tags), and the inverse Follow tags (Feature Request: Follow tags #872).

What: one list, two kinds of entry

Each entry is either:

  • Tag — an exact tag (crypto hides the tag and #crypto in a body).
  • Keyword/phrase — free text matched against the post's rendered plain text, with * wildcards.

Match rules (this is what makes it useful):

  • Case-insensitive.
  • Default whole-word, so cess does not hide "success" (the classic false-positive trap).
  • * = any run of characters, so the user opts into prefix/suffix/substring on purpose: crypto* → cryptocurrency, cryptobro; *coin → bitcoin, altcoin; *maxi* → anywhere.
  • A phrase ("machine learning") matches those words adjacent, in order.
  • Keyword entries match the post body and its tags/hashtags (muting the word crypto also catches #crypto); a Tag entry matches the tag only.

On a match, hide (do not vanish): collapse the post to a one-line placeholder ("Hidden: matches your filter crypto* · Show anyway") that reveals in place. A silently shorter feed confuses and breaks reply threads; a per-entry "remove completely" option can come later.

Never filter your own posts.

Private and one-directional: the list is yours alone. It never touches the author or anyone else, never notifies, never appears in the agent formats (.md/.txt/.json/.xml). It is sensitive (it reveals what a member dislikes: politics, health, identity), so it is owner-only, belongs in the GDPR export, and is never public.

UI sketch (a /settings page)

Muted words & tags                                   [ + Add ]
─────────────────────────────────────────────────────────────
 #crypto             tag                             Unmute
 crypto*             word · whole-word off           Unmute
 "machine learning"  phrase                          Unmute
 politics            word · until 12 Aug (snoozed)   Unmute
─────────────────────────────────────────────────────────────
 A hidden post shows a "Show anyway" line, never a gap.

Data model (new table)

Do not reuse the existing search_terms table: that is the fuzzy name-search index, unrelated. New table:

content_filters
  id          uuid v7  pk
  user_id     uuid v7  -> users        (owner; private)
  kind        enum     tag | keyword
  pattern     string   tag slug, or keyword/phrase with * wildcards
  whole_word  boolean  keyword only (default true; * overrides locally)
  expires_at  utc_datetime  null = permanent   (optional "snooze")
  timestamps

Entry points

  • Settings: a new /settings/filters row ("Muted words & tags"), or folded into Privacy.
  • Tag page (/tags/:slug): a "Mute this tag" button (the mirror of Feature Request: Follow tags #872's follow).
  • Post ⋯ menu: "Mute #tag" per tag on the post plus a quick "Mute a word…", right beside the existing "Mute @handle".

Engineering notes (the sharp edges)

  • Tags filter cheaply in the feed query (exclude posts whose post_tags intersect the muted set), exactly like the existing block/mute subqueries.
  • Keywords cannot be a join: compile the viewer's patterns once per request into one regex (glob *.*, word boundaries unless *), match the post's plain text (strip markdown and mentions so **crypto** still matches), filter in Elixir after hydrate_posts.
  • Pagination hazard: filtering after the DB page yields short pages and a wrong "Load more" count. Push tag exclusion into the query and top up keyword-filtered pages inside the cursor loop (or over-fetch), do not just Enum.reject/2 the finished page.
  • Cap the list size and pattern length, and validate the glob, so a pathological pattern cannot build a catastrophic regex.

Open questions / extensions

  • Search: add negative operators (-word, -tag:x) to Vutuv.Search, and let the filter list pre-apply on /search. (This is the original "tag exclusion on the search page" idea, generalised.)
  • Notifications about a muted-topic post: filter them too, or leave? (Lean: leave for v1.)
  • A muted tag should also drop out of your own suggested-tags list. (Yes.)

Related

#872 (Follow tags, the inverse), per-follow mute (follows.muted), block (Vutuv.Social). Follow-up to #872.

Written with this GitHub account, but not necessarily by the human behind it — this may just be a note-taking issue that gets deleted later without ever being tackled.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions