diff --git a/db.js b/db.js
index 6938ec1..ae95395 100644
--- a/db.js
+++ b/db.js
@@ -232,6 +232,18 @@ async function bootstrap() {
// sponsor CHAT (two-way) rides the same table: kind='chat' vs the default 'broadcast'
await alterSafe(`ALTER TABLE sponsor_messages ADD COLUMN kind VARCHAR(12) NOT NULL DEFAULT 'broadcast'`);
await alterSafe('ALTER TABLE sponsor_messages ADD INDEX idx_pair (to_email, from_email)');
+ // 2026-09-18: system notices used to be stored as 'broadcast', which is the kind the
+ // sign-in modal pops for — so a member with a backlog of payout notices got a fresh
+ // popup after every ad view. Retag the machine-generated ones so they stay in the inbox.
+ // Matched on subject, NOT on sender: these come from member 1 at ADMIN_EMAIL, which is
+ // also Marty's own member address, so his genuine broadcasts sit under the same sender.
+ // Idempotent — after the first pass nothing matches.
+ for (const like of ['You just got paid %POL on InstantAdPay',
+ 'You missed %POL on InstantAdPay',
+ '%is trying to buy. Link your wallet so it pays you']) {
+ try { await q("UPDATE sponsor_messages SET kind='notice' WHERE kind='broadcast' AND subject LIKE ?", [like]); }
+ catch (e) {}
+ }
// per-account chat settings: availability toggle (default on) + muted-member list (JSON emails)
await alterSafe('ALTER TABLE accounts ADD COLUMN chat_available TINYINT NOT NULL DEFAULT 1');
await alterSafe('ALTER TABLE accounts ADD COLUMN chat_mutes VARCHAR(4000) NULL');
diff --git a/messages.js b/messages.js
index 67ebf04..d01298e 100644
--- a/messages.js
+++ b/messages.js
@@ -27,32 +27,43 @@ async function lastBroadcastAt(fromEmail) {
return mine.length ? Math.max(...mine) : 0;
}
// deliver one message to many recipients (already-resolved emails). Returns count.
-async function deliver(fromMember, fromEmail, recipients, subject, body) {
+//
+// `kind` separates the two things that used to share this channel:
+// 'broadcast' — a person wrote it to their team. Rare, and worth interrupting for.
+// 'notice' — the system generated it (payout landed, purchase confirmed, and so on).
+// These arrive constantly, so they belong in the inbox and must never pop
+// a modal. Before this split they did, which meant a member with a backlog
+// of payout notices got a fresh popup after every single ad view.
+async function deliver(fromMember, fromEmail, recipients, subject, body, kind) {
+ const k = kind === 'notice' ? 'notice' : 'broadcast';
const now = Date.now();
let n = 0;
if (db.enabled()) {
for (const to of recipients) {
- await db.q("INSERT INTO sponsor_messages (from_member,from_email,to_email,subject,body,sent,kind) VALUES (?,?,?,?,?,?,'broadcast')",
- [fromMember || 0, fromEmail, to, subject, body, now]);
+ await db.q("INSERT INTO sponsor_messages (from_member,from_email,to_email,subject,body,sent,kind) VALUES (?,?,?,?,?,?,?)",
+ [fromMember || 0, fromEmail, to, subject, body, now, k]);
n++;
}
} else {
if (!J.db) J.load();
for (const to of recipients) {
- J.db.items.push({ id: J.db.nextId++, kind: 'broadcast', fromMember: fromMember || 0, fromEmail, toEmail: to, subject, body, sent: now, readTs: 0 });
+ J.db.items.push({ id: J.db.nextId++, kind: k, fromMember: fromMember || 0, fromEmail, toEmail: to, subject, body, sent: now, readTs: 0 });
n++;
}
J.save();
}
return n;
}
-const isBroadcast = i => (i.kind || 'broadcast') === 'broadcast';
+// Both kinds are inbox mail; only 'chat' is the separate two-way thread.
+const isBroadcast = i => (i.kind || 'broadcast') !== 'chat';
+// ...but only a message a PERSON wrote may interrupt with the modal.
+const isHuman = i => (i.kind || 'broadcast') === 'broadcast';
const isChat = i => i.kind === 'chat';
async function inbox(email) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
const rows = await db.q(`SELECT id, from_member, subject, body, sent, read_ts FROM sponsor_messages
- WHERE to_email=? AND kind='broadcast' ORDER BY sent DESC LIMIT 100`, [e]);
+ WHERE to_email=? AND kind<>'chat' ORDER BY sent DESC LIMIT 100`, [e]);
return rows.map(r => ({ id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body,
sent: Number(r.sent), read: !!r.read_ts }));
}
@@ -145,7 +156,7 @@ async function threadList(email) {
async function unreadCount(email) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
- const r = await db.q("SELECT COUNT(*) n FROM sponsor_messages WHERE to_email=? AND kind='broadcast' AND read_ts IS NULL", [e]);
+ const r = await db.q("SELECT COUNT(*) n FROM sponsor_messages WHERE to_email=? AND kind<>'chat' AND read_ts IS NULL", [e]);
return r[0].n;
}
if (!J.db) J.load();
@@ -162,7 +173,7 @@ async function newestUnread(email) {
return { id: r.id, fromMember: r.from_member, subject: r.subject, body: r.body, sent: Number(r.sent) };
}
if (!J.db) J.load();
- const u = J.db.items.filter(i => i.toEmail === e && isBroadcast(i) && !i.readTs).sort((a, b) => b.sent - a.sent)[0];
+ const u = J.db.items.filter(i => i.toEmail === e && isHuman(i) && !i.readTs).sort((a, b) => b.sent - a.sent)[0];
return u ? { id: u.id, fromMember: u.fromMember, subject: u.subject, body: u.body, sent: u.sent } : null;
}
async function markRead(email, id) {
diff --git a/public/assets/my.js b/public/assets/my.js
index a4b5a6a..baf7b2f 100644
--- a/public/assets/my.js
+++ b/public/assets/my.js
@@ -1290,8 +1290,18 @@
window.addEventListener('focus', () => { const p = $('vidPlayer'); if (p && p.src && !vidState.done) p.play().catch(() => {}); });
// ── unmissable sponsor-message modal on sign-in ──
+ // Only a message a PERSON wrote reaches here; system notices (payouts, purchase
+ // confirmations) are kind 'notice' and go straight to the inbox.
+ //
+ // loadDashboard() runs on far more than sign-in — it refreshes after every ad view, every
+ // campaign edit, every chat close. So the modal is shown at most once per page load and
+ // never twice for the same message, otherwise a member earning credits gets interrupted
+ // after every single view.
+ const msgModalShown = new Set();
function showSponsorModal(msg) {
- if (!$('msgModal')) return;
+ if (!$('msgModal') || !msg || msgModalShown.has(msg.id)) return;
+ if (!$('msgModal').hidden) return; // one already open
+ msgModalShown.add(msg.id);
$('mmFrom').textContent = 'A message from ' + (msg.fromName || 'your sponsor');
$('mmSubject').textContent = msg.subject || '';
$('mmBody').innerHTML = msg.body || ''; // server-sanitized
@@ -1299,6 +1309,7 @@
$('mmAck').onclick = async () => {
$('msgModal').hidden = true;
try { await fetch('/api/my/messages/' + msg.id + '/read', { method: 'POST' }); } catch (e) {}
+ setInboxBadge(Math.max(0, (Number($('inboxBadge') && $('inboxBadge').textContent) || 1) - 1));
};
}
diff --git a/public/my.html b/public/my.html
index 9d047a4..b8437ed 100644
--- a/public/my.html
+++ b/public/my.html
@@ -1033,7 +1033,7 @@
-
+