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 @@ - + diff --git a/qa/messages-notice.mjs b/qa/messages-notice.mjs new file mode 100644 index 0000000..0408527 --- /dev/null +++ b/qa/messages-notice.mjs @@ -0,0 +1,66 @@ +// The sign-in modal must interrupt for a message a PERSON wrote, and never for a +// system notice. Regression for the 2026-09-18 report: a member viewing daily ads got a +// fresh popup after every single view, because every payout notice was stored as a +// 'broadcast' and loadDashboard() re-pops on each refresh. +// +// node qa/messages-notice.mjs (JSON mode, no DB needed) +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +const require = createRequire(import.meta.url); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iap-msg-')); +const messages = require('../messages.js'); +messages.init({ dataDir: dir }); + +const ok = [], bad = []; +const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; + +const ME = 'earner@example.com'; + +// a real sponsor writing to their team +await messages.deliver(36, 'sponsor@example.com', [ME], 'Welcome to my line', '

hi

'); +// the system, reporting money that moved +await messages.deliver(1, 'house@instantadpay.com', [ME], 'You just got paid 40.8923 POL on InstantAdPay', '

paid

', 'notice'); +await messages.deliver(1, 'house@instantadpay.com', [ME], 'You missed 43.06 POL on InstantAdPay', '

missed

', 'notice'); + +const box = await messages.inbox(ME); +t('every message reaches the inbox', box.length === 3, String(box.length)); +t('the inbox badge counts notices too', (await messages.unreadCount(ME)) === 3, String(await messages.unreadCount(ME))); + +// the whole point +const un = await messages.newestUnread(ME); +t('the modal picks the human message, not the payout notice', + un && un.subject === 'Welcome to my line', un && un.subject); + +// acknowledge it; nothing should be left to interrupt with +await messages.markRead(ME, un.id); +const after = await messages.newestUnread(ME); +t('once acknowledged, nothing else pops', after === null, after && after.subject); +t('but the notices are still unread in the inbox', (await messages.unreadCount(ME)) === 2, + String(await messages.unreadCount(ME))); + +// a flood of notices must never produce a single interruption +for (let i = 0; i < 25; i++) { + await messages.deliver(1, 'house@instantadpay.com', [ME], 'You just got paid ' + i + ' POL on InstantAdPay', '

x

', 'notice'); +} +t('25 more payout notices still pop nothing', (await messages.newestUnread(ME)) === null); +t('and they all show up in the inbox', (await messages.inbox(ME)).length === 28, + String((await messages.inbox(ME)).length)); + +// an untagged deliver is still treated as a human broadcast (back-compat) +await messages.deliver(36, 'sponsor@example.com', [ME], 'Team call tonight', '

call

'); +const un2 = await messages.newestUnread(ME); +t('an untagged message still interrupts, as before', un2 && un2.subject === 'Team call tonight', un2 && un2.subject); + +// chat never pops the modal and never lands in the inbox list +await messages.sendChat(36, 'sponsor@example.com', ME, 'you around?'); +t('chat stays out of the inbox list', (await messages.inbox(ME)).length === 29, + String((await messages.inbox(ME)).length)); +t('chat is counted separately', (await messages.chatUnread(ME)) === 1, String(await messages.chatUnread(ME))); + +fs.rmSync(dir, { recursive: true, force: true }); +console.log('PASS ' + ok.length); +for (const b of bad) console.log('FAIL ' + b); +process.exit(bad.length ? 1 : 0); diff --git a/server.js b/server.js index 0ce53f6..5d8fa31 100644 --- a/server.js +++ b/server.js @@ -694,7 +694,7 @@ async function sponsorGainNudge(sp, buyerName, lostNames) { if (mailer.hasKey()) mailer.send(sp.email, subject, text + '\n\nInstantAdPay').catch(() => {}); try { const html = lines.map(l => '

' + l.replace(/&/g, '&').replace(/$1') + '

').join(''); - await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [sp.email], subject, html); + await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [sp.email], subject, html, 'notice'); } catch (e) {} } // anti-fraud admin alert (Telegram admin chat, else email): who, which flags, and whether the sign-up was blocked @@ -754,7 +754,7 @@ async function sponsorHoldNudge(tok, buyerEmail, routedTo) { if (mailer.hasKey()) mailer.send(sp.email, subject, text + '\n\nInstantAdPay').catch(() => {}); try { const html = lines.map(l => '

' + l.replace(/&/g, '&').replace(/$1') + '

').join(''); - await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [sp.email], subject, html); + await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [sp.email], subject, html, 'notice'); } catch (e) {} } // The moment someone joins through a code, nudge its owner to activate. @@ -832,7 +832,7 @@ async function emailOnEvent(ev) { if (!a || !a.email) return; const html = '

' + String(text).replace(/&/g, '&').replace(//g, '>') .replace(/(https:\/\/[^\s]+)/g, '$1').split('\n\n').join('

').replace(/\n/g, '
') + '

'; - await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [a.email], subject, html); + await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [a.email], subject, html, 'notice'); } catch (e) {} }; const tell = async (memberId, subject, text) => { await notify(memberId, subject, text); await inboxNote(memberId, subject, text); };