aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-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 {} }