feat: Discovery alert filters + notification reasons (v0.9.6)

- Add 4th "Discovery" tab in Alert Filters for member/topic follow notifications,
  with per-source enable toggle, independent event-type filters, and per-entity
  mute chips (mute specific members/topics without unfollowing)
- Enrich notification event payloads with follow_mode, matched_member_name,
  matched_member_id, and matched_topic so each event knows why it was created
- Dispatcher branches on payload.source for member_follow/topic_follow events,
  checking source-level enabled toggle, per-event-type filters, and muted_ids/muted_tags
- Add _build_reason helper; ntfy messages append a "why" line (📌/👤/🏷)
- EventRow in notification history shows a small italic reason line
- Update How It Works: fix stale member/topic paragraph, add Discovery alerts item

Authored-by: Jack Levy
This commit is contained in:
Jack Levy
2026-03-14 13:21:22 -04:00
parent 91473e6464
commit 247a874c8d
5 changed files with 281 additions and 15 deletions

View File

@@ -54,6 +54,7 @@ _FILTER_DEFAULTS = {
def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool:
payload = event.payload or {}
source = payload.get("source", "bill_follow")
# Map event type directly for document events
if event.event_type == "new_document":
@@ -69,6 +70,25 @@ def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool:
all_filters = prefs.get("alert_filters")
if all_filters is None:
return True # user hasn't configured filters yet — send everything
if source in ("member_follow", "topic_follow"):
source_filters = all_filters.get(source)
if source_filters is None:
return True # section not configured — send everything
if not source_filters.get("enabled", True):
return False # master toggle off
# Per-entity mute checks
if source == "member_follow":
muted_ids = source_filters.get("muted_ids") or []
if payload.get("matched_member_id") in muted_ids:
return False
if source == "topic_follow":
muted_tags = source_filters.get("muted_tags") or []
if payload.get("matched_topic") in muted_tags:
return False
return bool(source_filters.get(key, _FILTER_DEFAULTS.get(key, True)))
# Bill follow — use follow mode filters (existing behaviour)
mode_filters = all_filters.get(follow_mode) or {}
return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True)))
@@ -240,6 +260,21 @@ def send_notification_digest(self):
db.close()
def _build_reason(payload: dict) -> str | None:
source = payload.get("source", "bill_follow")
mode_labels = {"pocket_veto": "Pocket Veto", "pocket_boost": "Pocket Boost", "neutral": "Following"}
if source == "bill_follow":
mode = payload.get("follow_mode", "neutral")
return f"\U0001f4cc {mode_labels.get(mode, 'Following')} this bill"
if source == "member_follow":
name = payload.get("matched_member_name")
return f"\U0001f464 You follow {name}" if name else "\U0001f464 Member you follow"
if source == "topic_follow":
topic = payload.get("matched_topic")
return f"\U0001f3f7 You follow \"{topic}\"" if topic else "\U0001f3f7 Topic you follow"
return None
def _send_ntfy(
event: NotificationEvent,
topic_url: str,
@@ -260,6 +295,10 @@ def _send_ntfy(
if payload.get("brief_summary"):
lines.append("")
lines.append(payload["brief_summary"][:300])
reason = _build_reason(payload)
if reason:
lines.append("")
lines.append(reason)
message = "\n".join(lines) or bill_label
headers = {