aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 22:41:39 -0700
committerAdam Soltys <[email protected]>2026-08-02 22:41:39 -0700
commitd60697c8d1cc0b862d7488be4cf8ee21cafdfd1d (patch)
tree06919a327951f9979cb4ff7cf695bb03483757fd /src/features/messages.js
parent817b592bf3e6dbf3f6ef0d88a981efc6c2cc6dca (diff)
downloadcoinosv3-d60697c8d1cc0b862d7488be4cf8ee21cafdfd1d.tar.gz
coinosv3-d60697c8d1cc0b862d7488be4cf8ee21cafdfd1d.zip
Push notifications for payments and messages; offline Spending notice
The nwcpush notifier grows a second registration kind alongside NWC: a notify watch of what it can see without keys — kind-1059 wraps p-tagged at the user (DMs, direct invites), zap receipts (9735/9737), and wraps authored by the user's community channel keys (chat). It pushes a typed nudge, throttled per device (chat bursts collapse); the service worker shows a generic notification unless a window is visible, and tapping opens the app. The names registrar reports settled lightning-address receives server-to-server (token-gated /notify), so the common payment path notifies even with no nostr event in sight. The app registers silently whenever permission is already granted and keeps the watch current as communities change; a dismissible card on the chat home offers to enable it otherwise. Ark zap receipts (9737) join the relay whitelist so the payment watch can see them. Also: the offline Spending receive pane now says you're offline and offers a one-tap switch to Savings instead of spinning forever. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js67
1 files changed, 67 insertions, 0 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index e96cadb..6ff94a4 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -43,6 +43,7 @@ const COMMUNITY = {
const EPOCH = 0;
const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol'];
+const NOTIFIER = 'https://nwcpush.coinos.io';
const APP_BASE = 'https://v3.coinos.io';
const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
@@ -374,6 +375,7 @@ export function messagesFeature(ctx) {
(s.joined[cid] ||= {})[id.pubkey] = true;
save(s);
publishLists();
+ registerPush().catch(() => {});
ensureRoom(jm);
ui.msgView = 'room';
ui.msgCommunity = cid;
@@ -465,6 +467,7 @@ export function messagesFeature(ctx) {
}
const room = ensureRoom(communityById(b.community_id));
identity().then((id) => id && ensureJoined(room, id)).catch(() => {});
+ registerPush().catch(() => {});
ui.msgView = 'room';
ui.msgCommunity = b.community_id;
ui.msgChannel = null;
@@ -502,6 +505,55 @@ export function messagesFeature(ctx) {
const urlInvite = typeof location !== 'undefined' ? parseInviteLink(location.href) : null;
if (urlInvite) { try { history.replaceState(null, '', '/'); } catch {} }
+ // ---- push notifications -------------------------------------------------
+ // The nwcpush notifier watches relays for what it can see without keys:
+ // kind-1059 wraps p-tagged at us (DMs, direct invites), zap receipts
+ // (payments), and wraps authored by our communities' channel keys (chat).
+ // It pushes a typed nudge; the service worker shows a generic notification
+ // unless a window is visible. Payment pushes also arrive server-to-server
+ // from the names registrar when a lightning-address receive settles.
+
+ const b64ToBytes = (b64) => {
+ const pad = '='.repeat((4 - (b64.length % 4)) % 4);
+ const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/'));
+ return Uint8Array.from(raw, (c) => c.charCodeAt(0));
+ };
+
+ function pushWatch() {
+ const authors = [];
+ for (const jm of communities()) {
+ const root = hexToBytes(jm.community_root);
+ for (const c of (jm.channels || []).slice(0, 8)) authors.push(channelKey(root, c.id, EPOCH).pk);
+ }
+ return { ptags: myPubkeys(), authors };
+ }
+
+ async function registerPush({ interactive = false } = {}) {
+ try {
+ if (typeof Notification === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window)) return false;
+ if (Notification.permission !== 'granted') {
+ if (!interactive) return false;
+ if ((await Notification.requestPermission()) !== 'granted') return false;
+ }
+ const reg = await navigator.serviceWorker.ready;
+ let sub = await reg.pushManager.getSubscription();
+ if (!sub) {
+ const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json();
+ sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToBytes(publicKey) });
+ }
+ const r = await fetch(`${NOTIFIER}/register`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ subscription: sub.toJSON(), notify: pushWatch() }),
+ });
+ if (!r.ok) return false;
+ const s = st();
+ if (!s.push) { s.push = true; save(s); }
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
// ---- device sync: Community List (13302) + Invite List (13303) ----------
// Self-encrypted replaceables (CORD-02 §8 / CORD-05 §4) so memberships and
// minted link keys follow the user to any device or client. Published under
@@ -986,6 +1038,20 @@ export function messagesFeature(ctx) {
if (pendingLink) kids.push(linkInviteCard());
for (const [rid, inv] of pendingDirect) kids.push(directInviteCard(rid, inv));
+ // offer push once — it covers messages AND payments
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default' && !st().pushDismissed)
+ kids.push(h('div', { class: 'notice info col', style: 'gap:8px' },
+ h('div', {}, t('msgPushOffer')),
+ h('div', { class: 'row gap6' },
+ h('button', {
+ class: 'btn-primary btn-sm',
+ onClick: async () => { const ok = await registerPush({ interactive: true }); toast(ok ? t('msgPushOn') : t('msgPushFailed')); render(); },
+ }, t('msgPushEnable')),
+ h('button', {
+ class: 'btn-ghost btn-sm',
+ onClick: () => { const s = st(); s.pushDismissed = true; save(s); render(); },
+ }, t('msgDismiss')))));
+
// ---- DMs
kids.push(h('div', { class: 'row between', style: 'align-items:baseline' },
h('h3', { style: 'margin:0' }, t('msgDmsTitle')),
@@ -1303,6 +1369,7 @@ export function messagesFeature(ctx) {
}
startDMs();
syncLists().catch(() => {});
+ registerPush().catch(() => {}); // silent refresh when permission already granted
},
stop() {
for (const u of allUnsubs) { try { u(); } catch {} }