diff --git a/backend/app/workers/notification_dispatcher.py b/backend/app/workers/notification_dispatcher.py
index 70209c2..c45ce5a 100644
--- a/backend/app/workers/notification_dispatcher.py
+++ b/backend/app/workers/notification_dispatcher.py
@@ -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 = {
diff --git a/backend/app/workers/notification_utils.py b/backend/app/workers/notification_utils.py
index 439678e..9e5779a 100644
--- a/backend/app/workers/notification_utils.py
+++ b/backend/app/workers/notification_utils.py
@@ -75,7 +75,7 @@ def emit_bill_notification(
user_id=follow.user_id,
bill_id=bill.bill_id,
event_type=event_type,
- payload=payload,
+ payload={**payload, "follow_mode": follow.follow_mode},
))
count += 1
if count:
@@ -97,7 +97,11 @@ def emit_member_follow_notifications(
if not followers:
return 0
+ from app.models.member import Member
+ member = db.get(Member, bill.sponsor_id)
payload = _build_payload(bill, action_summary, action_category, source="member_follow")
+ payload["matched_member_name"] = member.name if member else None
+ payload["matched_member_id"] = bill.sponsor_id
count = 0
for follow in followers:
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
@@ -125,14 +129,16 @@ def emit_topic_follow_notifications(
from app.models.follow import Follow
from app.models.notification import NotificationEvent
- # Collect unique followers across all matching tags
+ # Collect unique followers across all matching tags, recording the first matching tag per user
seen_user_ids: set[int] = set()
followers = []
+ follower_topic: dict[int, str] = {}
for tag in topic_tags:
for follow in db.query(Follow).filter_by(follow_type="topic", follow_value=tag).all():
if follow.user_id not in seen_user_ids:
seen_user_ids.add(follow.user_id)
followers.append(follow)
+ follower_topic[follow.user_id] = tag
if not followers:
return 0
@@ -146,7 +152,7 @@ def emit_topic_follow_notifications(
user_id=follow.user_id,
bill_id=bill.bill_id,
event_type=event_type,
- payload=payload,
+ payload={**payload, "matched_topic": follower_topic.get(follow.user_id)},
))
count += 1
if count:
diff --git a/frontend/app/how-it-works/page.tsx b/frontend/app/how-it-works/page.tsx
index 485e5d3..e128c49 100644
--- a/frontend/app/how-it-works/page.tsx
+++ b/frontend/app/how-it-works/page.tsx
@@ -120,9 +120,12 @@ export default function HowItWorksPage() {
- You can also follow members (alerts when they sponsor new bills) and{" "}
- topics (alerts when new bills matching that topic are briefed).
- Member and topic follows use the Follow mode filters.
+ You can also follow members and topics.
+ When a followed member sponsors a bill, or a new bill matches a followed topic, you'll
+ receive a Discovery alert. These have their own independent filter set in{" "}
+ Notifications → Alert Filters → Discovery.
+ By default, all followed members and topics trigger notifications — you can mute individual
+ ones without unfollowing them.
@@ -176,6 +179,15 @@ export default function HowItWorksPage() {
A private, tokenized RSS feed of all your bill alerts. Subscribe in any RSS reader
(Feedly, NetNewsWire, etc.). Completely independent of ntfy.
+
+ Member and topic follows generate Discovery alerts — separate from the bills you follow
+ directly. In{" "}
+ Alert Filters → Discovery,
+ you can enable or disable these independently, tune which event types trigger them, and
+ mute specific members or topics you'd rather not hear about without unfollowing them.
+ Each notification also shows a “why” line so you always know which follow
+ triggered it.
+
diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx
index 0ef4774..08a92b1 100644
--- a/frontend/app/notifications/page.tsx
+++ b/frontend/app/notifications/page.tsx
@@ -1,13 +1,13 @@
"use client";
import { useState, useEffect, useRef } from "react";
-import { useQuery, useMutation } from "@tanstack/react-query";
+import { useQuery, useMutation, useQueries } from "@tanstack/react-query";
import {
Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
- FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter,
+ FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter, X,
} from "lucide-react";
import Link from "next/link";
-import { notificationsAPI, type NotificationTestResult } from "@/lib/api";
+import { notificationsAPI, membersAPI, type NotificationTestResult } from "@/lib/api";
import { useFollows } from "@/lib/hooks/useFollows";
import type { NotificationEvent } from "@/lib/types";
@@ -66,6 +66,25 @@ const MODES = [
] as const;
type ModeKey = "neutral" | "pocket_veto" | "pocket_boost";
+type DiscoverySourceKey = "member_follow" | "topic_follow";
+type FilterTabKey = ModeKey | "discovery";
+
+const DISCOVERY_SOURCES = [
+ {
+ key: "member_follow" as DiscoverySourceKey,
+ label: "Member Follows",
+ description: "Bills sponsored by members you follow",
+ preset: { new_document: false, new_amendment: false, vote: true, presidential: true,
+ committee_report: true, calendar: false, procedural: false, referral: false },
+ },
+ {
+ key: "topic_follow" as DiscoverySourceKey,
+ label: "Topic Follows",
+ description: "Bills matching topics you follow",
+ preset: { new_document: false, new_amendment: false, vote: true, presidential: true,
+ committee_report: false, calendar: false, procedural: false, referral: false },
+ },
+] as const;
function ModeFilterSection({
preset,
@@ -73,8 +92,8 @@ function ModeFilterSection({
onChange,
}: {
preset: Record;
- filters: Record;
- onChange: (f: Record) => void;
+ filters: Record;
+ onChange: (f: Record) => void;
}) {
const milestoneCheckRef = useRef(null);
const on = MILESTONE_KEYS.filter((k) => filters[k]);
@@ -217,14 +236,40 @@ export default function NotificationsPage() {
const [digestSaved, setDigestSaved] = useState(false);
// Alert filter state — one set of 8 filters per follow mode
- const [alertFilters, setAlertFilters] = useState>>({
+ const [alertFilters, setAlertFilters] = useState>>({
neutral: { ...ALL_OFF, ...MODES[0].preset },
pocket_veto: { ...ALL_OFF, ...MODES[1].preset },
pocket_boost: { ...ALL_OFF, ...MODES[2].preset },
});
- const [activeFilterTab, setActiveFilterTab] = useState("neutral");
+ const [discoveryFilters, setDiscoveryFilters] = useState>>({
+ member_follow: { enabled: true, ...DISCOVERY_SOURCES[0].preset },
+ topic_follow: { enabled: true, ...DISCOVERY_SOURCES[1].preset },
+ });
+ const [activeFilterTab, setActiveFilterTab] = useState("neutral");
const [filtersSaved, setFiltersSaved] = useState(false);
+ // Per-entity mute lists for Discovery — plain arrays; names resolved from memberById at render time
+ const [mutedMemberIds, setMutedMemberIds] = useState([]);
+ const [mutedTopicTags, setMutedTopicTags] = useState([]);
+
+ // Derive member/topic follows for the mute dropdowns
+ const memberFollows = follows.filter((f) => f.follow_type === "member");
+ const topicFollows = follows.filter((f) => f.follow_type === "topic");
+
+ // Batch-fetch member names so the "Mute a member…" dropdown shows real names
+ const memberQueries = useQueries({
+ queries: memberFollows.map((f) => ({
+ queryKey: ["member", f.follow_value],
+ queryFn: () => membersAPI.get(f.follow_value),
+ staleTime: 5 * 60 * 1000,
+ })),
+ });
+ const memberById: Record = Object.fromEntries(
+ memberFollows
+ .map((f, i) => [f.follow_value, memberQueries[i]?.data?.name])
+ .filter(([, name]) => name)
+ );
+
// Detect the browser's local timezone once on mount
useEffect(() => {
try {
@@ -259,6 +304,12 @@ export default function NotificationsPage() {
pocket_veto: { ...ALL_OFF, ...MODES[1].preset, ...(af.pocket_veto || {}) },
pocket_boost: { ...ALL_OFF, ...MODES[2].preset, ...(af.pocket_boost || {}) },
});
+ setDiscoveryFilters({
+ member_follow: { enabled: true, ...DISCOVERY_SOURCES[0].preset, ...(af.member_follow || {}) },
+ topic_follow: { enabled: true, ...DISCOVERY_SOURCES[1].preset, ...(af.topic_follow || {}) },
+ });
+ setMutedMemberIds(((af.member_follow as Record)?.muted_ids as string[]) || []);
+ setMutedTopicTags(((af.topic_follow as Record)?.muted_tags as string[]) || []);
}
}, [settings]);
@@ -310,7 +361,11 @@ export default function NotificationsPage() {
const saveAlertFilters = () => {
update.mutate(
- { alert_filters: alertFilters },
+ { alert_filters: {
+ ...alertFilters,
+ member_follow: { ...discoveryFilters.member_follow, muted_ids: mutedMemberIds },
+ topic_follow: { ...discoveryFilters.topic_follow, muted_tags: mutedTopicTags },
+ } },
{ onSuccess: () => { setFiltersSaved(true); setTimeout(() => setFiltersSaved(false), 2000); } }
);
};
@@ -506,6 +561,16 @@ export default function NotificationsPage() {
{mode.label}
))}
+
{/* Tab panels */}
@@ -528,6 +593,130 @@ export default function NotificationsPage() {
))}
+
+ {activeFilterTab === "discovery" && (
+