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:
@@ -54,6 +54,7 @@ _FILTER_DEFAULTS = {
|
|||||||
|
|
||||||
def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool:
|
def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool:
|
||||||
payload = event.payload or {}
|
payload = event.payload or {}
|
||||||
|
source = payload.get("source", "bill_follow")
|
||||||
|
|
||||||
# Map event type directly for document events
|
# Map event type directly for document events
|
||||||
if event.event_type == "new_document":
|
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")
|
all_filters = prefs.get("alert_filters")
|
||||||
if all_filters is None:
|
if all_filters is None:
|
||||||
return True # user hasn't configured filters yet — send everything
|
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 {}
|
mode_filters = all_filters.get(follow_mode) or {}
|
||||||
return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True)))
|
return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True)))
|
||||||
|
|
||||||
@@ -240,6 +260,21 @@ def send_notification_digest(self):
|
|||||||
db.close()
|
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(
|
def _send_ntfy(
|
||||||
event: NotificationEvent,
|
event: NotificationEvent,
|
||||||
topic_url: str,
|
topic_url: str,
|
||||||
@@ -260,6 +295,10 @@ def _send_ntfy(
|
|||||||
if payload.get("brief_summary"):
|
if payload.get("brief_summary"):
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(payload["brief_summary"][:300])
|
lines.append(payload["brief_summary"][:300])
|
||||||
|
reason = _build_reason(payload)
|
||||||
|
if reason:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(reason)
|
||||||
message = "\n".join(lines) or bill_label
|
message = "\n".join(lines) or bill_label
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def emit_bill_notification(
|
|||||||
user_id=follow.user_id,
|
user_id=follow.user_id,
|
||||||
bill_id=bill.bill_id,
|
bill_id=bill.bill_id,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
payload=payload,
|
payload={**payload, "follow_mode": follow.follow_mode},
|
||||||
))
|
))
|
||||||
count += 1
|
count += 1
|
||||||
if count:
|
if count:
|
||||||
@@ -97,7 +97,11 @@ def emit_member_follow_notifications(
|
|||||||
if not followers:
|
if not followers:
|
||||||
return 0
|
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 = _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
|
count = 0
|
||||||
for follow in followers:
|
for follow in followers:
|
||||||
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
|
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.follow import Follow
|
||||||
from app.models.notification import NotificationEvent
|
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()
|
seen_user_ids: set[int] = set()
|
||||||
followers = []
|
followers = []
|
||||||
|
follower_topic: dict[int, str] = {}
|
||||||
for tag in topic_tags:
|
for tag in topic_tags:
|
||||||
for follow in db.query(Follow).filter_by(follow_type="topic", follow_value=tag).all():
|
for follow in db.query(Follow).filter_by(follow_type="topic", follow_value=tag).all():
|
||||||
if follow.user_id not in seen_user_ids:
|
if follow.user_id not in seen_user_ids:
|
||||||
seen_user_ids.add(follow.user_id)
|
seen_user_ids.add(follow.user_id)
|
||||||
followers.append(follow)
|
followers.append(follow)
|
||||||
|
follower_topic[follow.user_id] = tag
|
||||||
|
|
||||||
if not followers:
|
if not followers:
|
||||||
return 0
|
return 0
|
||||||
@@ -146,7 +152,7 @@ def emit_topic_follow_notifications(
|
|||||||
user_id=follow.user_id,
|
user_id=follow.user_id,
|
||||||
bill_id=bill.bill_id,
|
bill_id=bill.bill_id,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
payload=payload,
|
payload={**payload, "matched_topic": follower_topic.get(follow.user_id)},
|
||||||
))
|
))
|
||||||
count += 1
|
count += 1
|
||||||
if count:
|
if count:
|
||||||
|
|||||||
@@ -120,9 +120,12 @@ export default function HowItWorksPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
You can also follow <strong>members</strong> (alerts when they sponsor new bills) and{" "}
|
You can also follow <strong>members</strong> and <strong>topics</strong>.
|
||||||
<strong>topics</strong> (alerts when new bills matching that topic are briefed).
|
When a followed member sponsors a bill, or a new bill matches a followed topic, you'll
|
||||||
Member and topic follows use the Follow mode filters.
|
receive a <em>Discovery</em> alert. These have their own independent filter set in{" "}
|
||||||
|
<Link href="/notifications" className="text-primary hover:underline">Notifications → Alert Filters → Discovery</Link>.
|
||||||
|
By default, all followed members and topics trigger notifications — you can mute individual
|
||||||
|
ones without unfollowing them.
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -176,6 +179,15 @@ export default function HowItWorksPage() {
|
|||||||
A private, tokenized RSS feed of all your bill alerts. Subscribe in any RSS reader
|
A private, tokenized RSS feed of all your bill alerts. Subscribe in any RSS reader
|
||||||
(Feedly, NetNewsWire, etc.). Completely independent of ntfy.
|
(Feedly, NetNewsWire, etc.). Completely independent of ntfy.
|
||||||
</Item>
|
</Item>
|
||||||
|
<Item icon={Filter} color="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" title="Discovery alerts">
|
||||||
|
Member and topic follows generate Discovery alerts — separate from the bills you follow
|
||||||
|
directly. In{" "}
|
||||||
|
<Link href="/notifications" className="text-primary hover:underline">Alert Filters → Discovery</Link>,
|
||||||
|
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.
|
||||||
|
</Item>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueries } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
|
Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
|
||||||
FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter,
|
FlaskConical, Clock, Calendar, FileText, AlertTriangle, Filter, X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
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 { useFollows } from "@/lib/hooks/useFollows";
|
||||||
import type { NotificationEvent } from "@/lib/types";
|
import type { NotificationEvent } from "@/lib/types";
|
||||||
|
|
||||||
@@ -66,6 +66,25 @@ const MODES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type ModeKey = "neutral" | "pocket_veto" | "pocket_boost";
|
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({
|
function ModeFilterSection({
|
||||||
preset,
|
preset,
|
||||||
@@ -73,8 +92,8 @@ function ModeFilterSection({
|
|||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
preset: Record<string, boolean>;
|
preset: Record<string, boolean>;
|
||||||
filters: Record<string, boolean>;
|
filters: Record<string, boolean | string[]>;
|
||||||
onChange: (f: Record<string, boolean>) => void;
|
onChange: (f: Record<string, boolean | string[]>) => void;
|
||||||
}) {
|
}) {
|
||||||
const milestoneCheckRef = useRef<HTMLInputElement>(null);
|
const milestoneCheckRef = useRef<HTMLInputElement>(null);
|
||||||
const on = MILESTONE_KEYS.filter((k) => filters[k]);
|
const on = MILESTONE_KEYS.filter((k) => filters[k]);
|
||||||
@@ -217,14 +236,40 @@ export default function NotificationsPage() {
|
|||||||
const [digestSaved, setDigestSaved] = useState(false);
|
const [digestSaved, setDigestSaved] = useState(false);
|
||||||
|
|
||||||
// Alert filter state — one set of 8 filters per follow mode
|
// Alert filter state — one set of 8 filters per follow mode
|
||||||
const [alertFilters, setAlertFilters] = useState<Record<ModeKey, Record<string, boolean>>>({
|
const [alertFilters, setAlertFilters] = useState<Record<ModeKey, Record<string, boolean | string[]>>>({
|
||||||
neutral: { ...ALL_OFF, ...MODES[0].preset },
|
neutral: { ...ALL_OFF, ...MODES[0].preset },
|
||||||
pocket_veto: { ...ALL_OFF, ...MODES[1].preset },
|
pocket_veto: { ...ALL_OFF, ...MODES[1].preset },
|
||||||
pocket_boost: { ...ALL_OFF, ...MODES[2].preset },
|
pocket_boost: { ...ALL_OFF, ...MODES[2].preset },
|
||||||
});
|
});
|
||||||
const [activeFilterTab, setActiveFilterTab] = useState<ModeKey>("neutral");
|
const [discoveryFilters, setDiscoveryFilters] = useState<Record<DiscoverySourceKey, Record<string, boolean | string[]>>>({
|
||||||
|
member_follow: { enabled: true, ...DISCOVERY_SOURCES[0].preset },
|
||||||
|
topic_follow: { enabled: true, ...DISCOVERY_SOURCES[1].preset },
|
||||||
|
});
|
||||||
|
const [activeFilterTab, setActiveFilterTab] = useState<FilterTabKey>("neutral");
|
||||||
const [filtersSaved, setFiltersSaved] = useState(false);
|
const [filtersSaved, setFiltersSaved] = useState(false);
|
||||||
|
|
||||||
|
// Per-entity mute lists for Discovery — plain arrays; names resolved from memberById at render time
|
||||||
|
const [mutedMemberIds, setMutedMemberIds] = useState<string[]>([]);
|
||||||
|
const [mutedTopicTags, setMutedTopicTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 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<string, string> = 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
|
// Detect the browser's local timezone once on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -259,6 +304,12 @@ export default function NotificationsPage() {
|
|||||||
pocket_veto: { ...ALL_OFF, ...MODES[1].preset, ...(af.pocket_veto || {}) },
|
pocket_veto: { ...ALL_OFF, ...MODES[1].preset, ...(af.pocket_veto || {}) },
|
||||||
pocket_boost: { ...ALL_OFF, ...MODES[2].preset, ...(af.pocket_boost || {}) },
|
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<string, unknown>)?.muted_ids as string[]) || []);
|
||||||
|
setMutedTopicTags(((af.topic_follow as Record<string, unknown>)?.muted_tags as string[]) || []);
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
@@ -310,7 +361,11 @@ export default function NotificationsPage() {
|
|||||||
|
|
||||||
const saveAlertFilters = () => {
|
const saveAlertFilters = () => {
|
||||||
update.mutate(
|
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); } }
|
{ onSuccess: () => { setFiltersSaved(true); setTimeout(() => setFiltersSaved(false), 2000); } }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -506,6 +561,16 @@ export default function NotificationsPage() {
|
|||||||
{mode.label}
|
{mode.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveFilterTab("discovery")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
|
activeFilterTab === "discovery"
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Discovery
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab panels */}
|
{/* Tab panels */}
|
||||||
@@ -528,6 +593,130 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{activeFilterTab === "discovery" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Member follows */}
|
||||||
|
{(() => {
|
||||||
|
const src = DISCOVERY_SOURCES[0];
|
||||||
|
const srcFilters = discoveryFilters[src.key];
|
||||||
|
const { enabled, ...alertOnly } = srcFilters;
|
||||||
|
const unmutedMembers = memberFollows.filter((f) => !mutedMemberIds.includes(f.follow_value));
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={!!enabled}
|
||||||
|
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
||||||
|
className="rounded" />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Notify me about bills from member follows</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{!!enabled && (
|
||||||
|
<div className="ml-6 space-y-4">
|
||||||
|
<ModeFilterSection preset={src.preset} filters={alertOnly}
|
||||||
|
onChange={(f) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...f, enabled: true } }))} />
|
||||||
|
{/* Muted members */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Muted members</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{mutedMemberIds.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground italic">None — all followed members will trigger notifications</span>
|
||||||
|
) : mutedMemberIds.map((id) => (
|
||||||
|
<span key={id} className="inline-flex items-center gap-1.5 text-xs border border-border bg-muted rounded-full px-2.5 py-1">
|
||||||
|
<span>{memberById[id] ?? id}</span>
|
||||||
|
<button onClick={() => setMutedMemberIds((prev) => prev.filter((x) => x !== id))}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors leading-none">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{unmutedMembers.length > 0 && (
|
||||||
|
<select value=""
|
||||||
|
onChange={(e) => { const id = e.target.value; if (id) setMutedMemberIds((prev) => [...prev, id]); }}
|
||||||
|
className="text-xs px-2 py-1.5 bg-background border border-border rounded-md">
|
||||||
|
<option value="" disabled>Mute a member…</option>
|
||||||
|
{unmutedMembers.map((f) => (
|
||||||
|
<option key={f.follow_value} value={f.follow_value}>
|
||||||
|
{memberById[f.follow_value] ?? f.follow_value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
|
{/* Topic follows */}
|
||||||
|
{(() => {
|
||||||
|
const src = DISCOVERY_SOURCES[1];
|
||||||
|
const srcFilters = discoveryFilters[src.key];
|
||||||
|
const { enabled, ...alertOnly } = srcFilters;
|
||||||
|
const unmutedTopics = topicFollows.filter((f) => !mutedTopicTags.includes(f.follow_value));
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={!!enabled}
|
||||||
|
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
||||||
|
className="rounded" />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Notify me about bills from topic follows</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{!!enabled && (
|
||||||
|
<div className="ml-6 space-y-4">
|
||||||
|
<ModeFilterSection preset={src.preset} filters={alertOnly}
|
||||||
|
onChange={(f) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...f, enabled: true } }))} />
|
||||||
|
{/* Muted topics */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Muted topics</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{mutedTopicTags.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground italic">None — all followed topics will trigger notifications</span>
|
||||||
|
) : mutedTopicTags.map((tag) => (
|
||||||
|
<span key={tag} className="inline-flex items-center gap-1.5 text-xs border border-border bg-muted rounded-full px-2.5 py-1">
|
||||||
|
<span>{tag}</span>
|
||||||
|
<button onClick={() => setMutedTopicTags((prev) => prev.filter((x) => x !== tag))}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors leading-none">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{unmutedTopics.length > 0 && (
|
||||||
|
<select value=""
|
||||||
|
onChange={(e) => { const tag = e.target.value; if (tag) setMutedTopicTags((prev) => [...prev, tag]); }}
|
||||||
|
className="text-xs px-2 py-1.5 bg-background border border-border rounded-md">
|
||||||
|
<option value="" disabled>Mute a topic…</option>
|
||||||
|
{unmutedTopics.map((f) => (
|
||||||
|
<option key={f.follow_value} value={f.follow_value}>{f.follow_value}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-border flex justify-end">
|
||||||
|
<button onClick={saveAlertFilters} disabled={update.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors">
|
||||||
|
{filtersSaved ? <CheckCircle className="w-3.5 h-3.5" /> : null}
|
||||||
|
{filtersSaved ? "Saved!" : "Save Discovery Filters"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Quiet Hours */}
|
{/* Quiet Hours */}
|
||||||
@@ -734,6 +923,26 @@ export default function NotificationsPage() {
|
|||||||
{billTitle && (
|
{billTitle && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{billTitle}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{billTitle}</p>
|
||||||
)}
|
)}
|
||||||
|
{(() => {
|
||||||
|
const src = p.source as string | undefined;
|
||||||
|
const modeLabels: Record<string, string> = {
|
||||||
|
pocket_veto: "Pocket Veto", pocket_boost: "Pocket Boost", neutral: "Following",
|
||||||
|
};
|
||||||
|
let reason: string | null = null;
|
||||||
|
if (src === "bill_follow") {
|
||||||
|
const mode = p.follow_mode as string | undefined;
|
||||||
|
reason = mode ? `${modeLabels[mode] ?? "Following"} this bill` : null;
|
||||||
|
} else if (src === "member_follow") {
|
||||||
|
const name = p.matched_member_name as string | undefined;
|
||||||
|
reason = name ? `You follow ${name}` : "Member you follow";
|
||||||
|
} else if (src === "topic_follow") {
|
||||||
|
const topic = p.matched_topic as string | undefined;
|
||||||
|
reason = topic ? `You follow "${topic}"` : "Topic you follow";
|
||||||
|
}
|
||||||
|
return reason ? (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 italic">{reason}</p>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
{briefSummary && (
|
{briefSummary && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{briefSummary}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{briefSummary}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export interface NotificationSettings {
|
|||||||
quiet_hours_start: number | null;
|
quiet_hours_start: number | null;
|
||||||
quiet_hours_end: number | null;
|
quiet_hours_end: number | null;
|
||||||
timezone: string | null; // IANA name, e.g. "America/New_York"
|
timezone: string | null; // IANA name, e.g. "America/New_York"
|
||||||
alert_filters: Record<string, Record<string, boolean>> | null;
|
alert_filters: Record<string, Record<string, boolean | string[]>> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Collection {
|
export interface Collection {
|
||||||
|
|||||||
Reference in New Issue
Block a user