@yz-social/civildefense.io 4.5.29 → 5.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/index.js +1 -0
  2. package/package.json +4 -2
  3. package/public/about/add-topic-autocomplete.png +0 -0
  4. package/public/about/add-topic.png +0 -0
  5. package/public/about/can-happen.jpg +0 -0
  6. package/public/about/en.html +72 -30
  7. package/public/about/identity.html +48 -0
  8. package/public/about/index.html +1 -1
  9. package/public/about/join.webp +0 -0
  10. package/public/about/notification.html +71 -0
  11. package/public/about/posters.jpeg +0 -0
  12. package/public/about/pwa.html +61 -0
  13. package/public/about/style.css +15 -2
  14. package/public/about/template.html +28 -0
  15. package/public/about/topics.png +0 -0
  16. package/public/about/train-radio-button.png +0 -0
  17. package/public/about/train.png +0 -0
  18. package/public/about/ukraine-fires.jpeg +0 -0
  19. package/public/about/ukraine-fires.png +0 -0
  20. package/public/index.html +17 -9
  21. package/public/javascripts/alert.js +203 -119
  22. package/public/javascripts/browser-push.js +56 -0
  23. package/public/javascripts/conversation.js +6 -3
  24. package/public/javascripts/display.js +2 -1
  25. package/public/javascripts/hashtags.js +3 -1
  26. package/public/javascripts/main.js +102 -64
  27. package/public/javascripts/map.js +13 -6
  28. package/public/javascripts/p2pWebNetwork.js +37 -4
  29. package/public/javascripts/protocol.js +3 -3
  30. package/public/javascripts/pubsub.js +107 -98
  31. package/public/javascripts/push.js +28 -0
  32. package/public/javascripts/s2.js +24 -2
  33. package/public/javascripts/service-manager.js +11 -7
  34. package/public/javascripts/store-memory.js +74 -0
  35. package/public/javascripts/store-redis.js +101 -0
  36. package/public/javascripts/translations.js +6 -6
  37. package/public/service-worker.js +50 -5
  38. package/public/stylesheets/style.css +50 -17
  39. package/server/websocket.js +12 -5
@@ -1,5 +1,5 @@
1
1
  const { pica, FileReader, File, URL } = globalThis;
2
- import { resetInactivityTimer } from './main.js';
2
+ import { closeAll, resetInactivityTimer } from './main.js';
3
3
  import { showMessage } from './map.js';
4
4
 
5
5
  export function consume(event) { // i.e., don't close dialogs
@@ -11,6 +11,7 @@ export function openDisplay(containerIdentifier, event = null, content = undefin
11
11
  // Open containerIdentifier with close handler attached and optional content, and return content element
12
12
  const containerElement = document.getElementById(containerIdentifier);
13
13
  consume(event);
14
+ closeAll();
14
15
 
15
16
  containerElement.onclick = event => {
16
17
  resetInactivityTimer();
@@ -23,6 +23,7 @@ let allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
23
23
  "🎙️ news",
24
24
  "👁️ observer corps",
25
25
  "🎵 pop",
26
+ "🚂 train",
26
27
  "🧰 utility repairs"
27
28
  ]`);
28
29
 
@@ -125,7 +126,8 @@ export const Hashtags = {
125
126
  if (resetSubscriptions) {
126
127
  // We destroy unsubscribed markers right away, because we don't want the user to have to wait and wonder why they're still displayed.
127
128
  // If there are alerts in flight, they will be rejected by Alert initialize because we will have already turned off the sub.
128
- Alert.items.forEach(wrapper => this.isSubscribed(wrapper.hashtag) || wrapper.destroy());
129
+ // The remaining have needsRedisplay set so that their menus are correct.
130
+ Alert.items.forEach(wrapper => (this.isSubscribed(wrapper.hashtag) && (wrapper.needsRedisplay = true)) || wrapper.destroy());
129
131
  Alert.updateSubscriptions();
130
132
  }
131
133
  },
@@ -4,10 +4,13 @@ import { Int } from './translations.js';
4
4
  import { openDisplay } from './display.js';
5
5
  import { Agent} from './agent.js';
6
6
  import { P2PWebNetwork } from './p2pWebNetwork.js';
7
- import { getPointInCell } from './s2.js';
7
+ import { getPointInCell, getSmallestCellSurrounding } from './s2.js';
8
8
  import { Alert, getShareableURL, share } from './alert.js';
9
+ import { Hashtags } from './hashtags.js';
9
10
  import { map, showMessage, updateLocation, recenterMap } from './map.js';
10
- import './service-manager.js'; // Comment this out and kill service-workers for reload-to-get-latest behavior during development.
11
+ import {postServiceMessage} from './service-manager.js'; // Comment this out and kill service-workers for reload-to-get-latest behavior during development.
12
+ //const postServiceMessage = null;
13
+ export {postServiceMessage};
11
14
  window.P2PWebNetwork = P2PWebNetwork;
12
15
 
13
16
  document.getElementById('appVersion').textContent = appVersion;
@@ -24,8 +27,6 @@ var inactivityTimer = null, reconnectCountdown, networkPromise = null;
24
27
  export { networkPromise };
25
28
  export async function resetInactivityTimer(clearMessage = true) { // if !network, initialize(false), else disconnect after INACTIVITY_SECONDSif not restarted
26
29
  //console.log('resetInactivityTimer, networkPromise:', networkPromise);
27
- document.body.classList.toggle('firstConversation', false);
28
- document.body.classList.toggle('firstPublish', false);
29
30
  if (clearMessage) showMessage('');
30
31
  clearTimeout(inactivityTimer);
31
32
  clearInterval(reconnectCountdown);
@@ -58,7 +59,7 @@ function isWebView() { return /CriOS|(WebView|wv|(iPhone|iPod|iPad)(?!.*Safari))
58
59
  function isApple() { return navigator.platform.startsWith('Mac') || ['iPhone', 'iPad'].includes(navigator.platform); }
59
60
  function isMobile() { return navigator.userAgentData?.mobile || /iPhone|iPad|iPod|Mobile/.test(navigator.userAgent); }
60
61
  function isStandalone() { return window.matchMedia('(display-mode: standalone)').matches; }
61
- export function osName() { return navigator.userAgentData?.platform || navigator.userAgent.match(/Android/)?.[0] || navigator.platform; }
62
+ export function osName() { return isApple() ? (isMobile() ? 'iOS' : 'OSX') : navigator.userAgentData?.platform || navigator.userAgent.match(/Android/)?.[0] || navigator.platform; }
62
63
  function mobilePlatformName() { return isMobile() && (isApple() ? 'iOS' : 'Android'); }
63
64
  function mobileVendorName() { return isMobile() && (isApple() ? 'Apple' : 'Android'); }
64
65
  function mobileBrowserName() { return isApple() ? 'Safari' : 'Chrome'; }
@@ -73,44 +74,54 @@ function browserName() {
73
74
 
74
75
  var showNotifications = document.getElementById('showNotifications');
75
76
  var showNotificationsLabel = document.getElementById('showNotificationsLabel');
76
- function disabledNotifications() { return localStorage.getItem('disabledNotifications'); }
77
- export function disableNotifications(force) { localStorage.setItem('disabledNotifications', force ? '1' : ''); }
78
- export function notificationsAllowed() { return (Notification?.permission === 'granted') && !disabledNotifications(); }
79
- function noteNotificationPermission(permission) {
77
+ function notificationsRequested() { // Explicit choice by user in app, but might be currently denied by OS.
78
+ return !!localStorage.getItem('notificationsRequested');
79
+ }
80
+ export function notificationsAllowed() { // Combined result.
81
+ return postServiceMessage && (Notification?.permission === 'granted') && notificationsRequested();
82
+ }
83
+ let notificationsPreviouslyAllowed = notificationsAllowed();
84
+ function noteNotificationPermission(permission) { // Update permission controls/text/persistence.
85
+ // Called when checkbox is toggled, opening dialog, or if platform happens to tell us permission was externally changed (e.g., even if dialog already open).
80
86
  if (isWebView()) {
87
+ showNotifications.checked = false;
81
88
  showNotifications.indeterminate = true;
82
89
  showNotifications.toggleAttribute('disabled', true);
83
90
  showNotificationsLabel.innerHTML = `${mobileVendorName()} ${Int`does not support notifications on WebViews embedded in other programs. Please use CivilDefense.io in native`} ${mobileBrowserName()}.`;
84
- return;
85
- }
86
- if (isMobile() && isApple() && !isStandalone()) {
91
+ } else if (isMobile() && isApple() && !isStandalone()) {
92
+ showNotifications.checked = false;
87
93
  showNotifications.indeterminate = true;
88
94
  showNotifications.toggleAttribute('disabled', true);
89
95
  showNotificationsLabel.innerHTML = `${Int`Apple only supports mobile notifications for web pages that have been`} <a href="https://www.google.com/search?q=iphone+install+web+page+to+home+screen" target="yz.sidebar">${Int`installed to the home screen`}</a>.`;
90
- return;
91
- }
92
- switch (permission) {
93
- case 'default':
94
- showNotifications.indeterminate = true;
95
- showNotifications.toggleAttribute('disabled', false);
96
- showNotificationsLabel.innerHTML = Int`Enable notifications`;
97
- break;
98
- case 'granted':
99
- showNotifications.checked = !disabledNotifications();
100
- showNotifications.toggleAttribute('disabled', false);
101
- showNotificationsLabel.innerHTML = Int`Allow notifications`;
102
- break;
103
- default:
104
- showNotifications.checked = false;
105
- showNotifications.toggleAttribute('disabled', true);
106
- const isApp = isStandalone();
107
- const search = isApp ?
108
- `https://www.google.com/search?q=${osName()}+open+app+settings` :
109
- `https://www.google.com/search?q=open+site+settings+${browserName() || `"${navigator.userAgent}"`}`;
110
- const label = isApp ? `CivilDefense.io ${Int`app`}` : Int`browser site settings`;
111
- showNotificationsLabel.innerHTML = `${Int`Permissions can be re-enabled through the`} <a href="${search}" target="yz.sidebar">${label}</a>.`;
112
- break;
96
+ } else {
97
+ switch (permission) {
98
+ case 'default':
99
+ showNotifications.indeterminate = true;
100
+ showNotifications.toggleAttribute('disabled', false);
101
+ showNotificationsLabel.innerHTML = Int`Enable notifications`;
102
+ break;
103
+ case 'granted':
104
+ showNotifications.checked = notificationsRequested();
105
+ showNotifications.indeterminate = false;
106
+ showNotifications.toggleAttribute('disabled', false);
107
+ showNotificationsLabel.innerHTML = Int`Allow notifications`;
108
+ break;
109
+ default: // Permission is explicitly not granted.
110
+ showNotifications.checked = false;
111
+ showNotifications.indeterminate = false;
112
+ showNotifications.toggleAttribute('disabled', true);
113
+ const isApp = isStandalone();
114
+ const search = isApp ?
115
+ `https://www.google.com/search?q=${osName()}+open+app+settings` :
116
+ `https://www.google.com/search?q=open+site+settings+${browserName() || `"${navigator.userAgent}"`}`;
117
+ const label = isApp ? `CivilDefense.io ${Int`app`}` : Int`browser site settings`;
118
+ showNotificationsLabel.innerHTML = `${Int`Permissions can be re-enabled through the`} <a href="${search}" target="yz.sidebar">${label}</a>.`;
119
+ break;
120
+ }
113
121
  }
122
+ if (showNotifications.checked === notificationsPreviouslyAllowed) return;
123
+ notificationsPreviouslyAllowed = showNotifications.checked;
124
+ Alert.updateSubscriptions('stickyChange');
114
125
  }
115
126
  clickTip(showNotifications.parentElement, Int`Enable local ${osName()} notifcations for map alerts, without going through any servers. Requires that the app be running.`, event => {
116
127
  resetInactivityTimer();
@@ -118,15 +129,23 @@ clickTip(showNotifications.parentElement, Int`Enable local ${osName()} notifcati
118
129
  });
119
130
  showNotifications.onchange = () => {
120
131
  if (window.Notification?.permission === 'granted') {
121
- disableNotifications(!showNotifications.checked);
132
+ localStorage.setItem('notificationsRequested', showNotifications.checked ? '1' : '');
133
+ noteNotificationPermission('granted');
122
134
  } else {
123
- window.Notification?.requestPermission().then(noteNotificationPermission);
135
+ window.Notification?.requestPermission().then(noteNotificationPermission());
124
136
  }
125
137
  };
126
138
  // Safari never fires 'change': https://webkit.org/b/259432
127
139
  navigator.permissions.query({ name: 'notifications'})
128
140
  .then(status => status.onchange = () => noteNotificationPermission(window.Notification?.permission));
129
141
 
142
+ export function closeAll() { // Close anything that might be open.
143
+ closeAbout();
144
+ Alert.closePopup();
145
+ Hashtags.closeSelector();
146
+ ['aboutContainer', 'updateContainer', 'correspondentContainer', 'qrContainer']
147
+ .forEach(tag => document.getElementById(tag).classList.toggle('hidden', true));
148
+ }
130
149
  export function openAbout(event) {
131
150
  openDisplay('aboutContainer', event);
132
151
  noteNotificationPermission(window.Notification?.permission);
@@ -134,17 +153,6 @@ export function openAbout(event) {
134
153
  export function closeAbout() {
135
154
  document.getElementById('aboutContainer').classList.toggle('hidden', true);
136
155
  }
137
- clickTip('#aboutButton', Int`Information about this app, and options to change notifications or how you appear to others.`, event => { // open about
138
- Alert.closePopup();
139
- openAbout(event);
140
- });
141
- clickTip('#wipe', Int`Wipe from ${osName()} all personal data and source files for this app.`, async event => {
142
- await networkPromise?.then(contact => contact.disconnect());
143
- localStorage.clear();
144
- await caches.keys().then(keys => Promise.all(keys.map(key => caches.delete(key))));
145
- await navigator.serviceWorker.getRegistrations().then(registrations => Promise.all(registrations.map(r => r.unregister())));
146
- window.location.replace('about/index.html');
147
- });
148
156
  document.getElementById('scriptChooser').onchange = event => { // Run a script module chosen by the user. e.g., for testing.
149
157
  const file = event.currentTarget.files[0];
150
158
  if (!file) return;
@@ -184,10 +192,11 @@ document.querySelector('#correspondentContainer md-outlined-text-field').onclick
184
192
  event.stopPropagation();
185
193
  };
186
194
 
187
- clickTip('#share', Int`${osName()} share a link to this map and topics, with picture of map.`, event => {
188
- event.stopPropagation();
195
+ export function shareMap(event) {
196
+ event?.stopPropagation();
189
197
  share({text: "CivilDefense.io", url: getShareableURL().href });
190
- });
198
+ }
199
+ clickTip('#share', Int`${osName()} share a link to this map and topics, with picture of map.`, shareMap);
191
200
 
192
201
  clickTip('#recenterButton', Int`Recenter the map to where you are in the world.`, recenterMap);
193
202
 
@@ -218,6 +227,7 @@ export let positionWatch;
218
227
  let subscribeOneShot;
219
228
  const goodPositionLabel = Int`Your Location`;
220
229
 
230
+ export let lastCells = null;
221
231
  function initializeGeolocation(subscribe = false) { // Arrange to constantly updateLocation, but:
222
232
  // Arrange to constantly updateLocation when geolocation updates (or a default init if that's not possible).
223
233
  // Once updateLocation has been called, updateSubscriptions() if the subscribe arg is true.
@@ -248,13 +258,15 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
248
258
  zoom = 13;
249
259
  [lat, lng] = [37.7749, -122.4194]; // San Fransisco
250
260
  }
261
+ } else {
262
+ lastCells = getSmallestCellSurrounding(lat, lng);
251
263
  }
252
264
  //console.log('initializeGeolocation updateLocation');
253
265
  updateLocation(lat, lng, zoom, positionLabel);
254
266
  if (!subscribeOneShot) return;
255
267
  subscribeOneShot = false;
256
268
  resetInactivityTimer(false);
257
- Alert.updateSubscriptions({oldKeys: {}}); // This was for a new node, so supply an empty oldSubscriptions.
269
+ Alert.updateSubscriptions('newNode');
258
270
  };
259
271
  if (!geolocation) {
260
272
  showMessage(Int`Geolocation not supported. Using default location.`, 'error', 'fail');
@@ -276,7 +288,7 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
276
288
  delay(2e3).then(() => initMap());
277
289
  } else {
278
290
  showMessage(Int`Unable to get location.`, 'error', error);
279
- delay(4e3).then(() => initializeGeolocation(subscribe));
291
+ delay(2e3).then(() => initMap());
280
292
  }
281
293
  } else {
282
294
  showMessage(Int`No network connection.`, 'error');
@@ -320,7 +332,8 @@ async function initialize(event) { // Ensure there is a network promise and map,
320
332
  const {promise, resolve} = Promise.withResolvers();
321
333
  networkPromise = promise;
322
334
  console.log('Creating node.');
323
- resolve(P2PWebNetwork.create({}));
335
+ Alert.clearPushData();
336
+ resolve(await P2PWebNetwork.create({pushPersistor: postServiceMessage && localStorage}));
324
337
  networkPromise.then(contact => {
325
338
  globalThis.contact = contact; // For debugging.
326
339
  // On leaving, we would like to copy stored data and politely say 'bye' (so others can clean up their connections). Alas:
@@ -366,11 +379,21 @@ async function initialize(event) { // Ensure there is a network promise and map,
366
379
  }
367
380
  document.addEventListener('visibilitychange', initialize);
368
381
  window.addEventListener('online', initialize);
382
+ window.addEventListener("appinstalled", async () => {
383
+ // It turns out that the same browser that support this push the EXISTING browsing context into their own frame,
384
+ // rather than starting fresh. (Which makes some sense for having this event fire.)
385
+ // In such cases, we need to re-initialize stuff. We could go point by point, but for now, just hit everything.
386
+ location.reload();
387
+ });
388
+ document.body.addEventListener('animationend', event => {
389
+ event.target.style.animation = '';
390
+ });
369
391
 
370
- const titleElement = document.querySelector('#aboutContainer h1');
371
- titleElement.textContent = Int([titleElement.textContent]);
372
- titleElement.textContent += ` @${location.hostname}`;
373
- document.querySelector('head > title').innerHTML = titleElement.textContent;
392
+ const titleLabel = document.querySelector('#titleLabel');
393
+ titleLabel.textContent = Int([titleLabel.textContent]);
394
+ const titleHostname = document.querySelector('#titleHostname');
395
+ titleHostname.textContent = ` @${location.hostname}`;
396
+ document.querySelector('head > title').innerHTML = titleLabel.textContent + titleHostname.textContent;
374
397
  // Set up text for the browser language.
375
398
  function initText(selector, content = selector) {
376
399
  const element = document.querySelector(selector);
@@ -380,10 +403,6 @@ function initText(selector, content = selector) {
380
403
  initText('#aboutReport');
381
404
  initText('#aboutShared');
382
405
  initText('#aboutFade');
383
- initText('#aboutAnyone1');
384
- initText('#aboutAnyone2');
385
- initText('#aboutAnyone3');
386
- initText('#learnMore');
387
406
  initText('#version');
388
407
  initText('#checkForUpdates');
389
408
  initText('#downloadUpdates');
@@ -398,14 +417,33 @@ initText('#describePrivate2');
398
417
  initText('#describePublic');
399
418
  initText('#describeSystem');
400
419
  initText('#pickLabels');
401
- initText('#wipe');
402
420
  initText('.firstConversation .teach.correspondent');
403
421
  initText('.firstPublish .teach.correspondent');
404
422
  initText('.teach.share');
405
423
  initText('.teach.attach');
406
424
  initText('.teach.changeHashtag');
407
425
 
426
+ clickTip('#aboutButton', Int`Information about this app, and options to change notifications or how you appear to others.`, event => { // open about
427
+ closeAll();
428
+ openAbout(event);
429
+ });
430
+
431
+ if (isStandalone()) {
432
+ document.querySelector('#wipe').textContent = Int`Installed`;
433
+ } else {
434
+ initText('#wipe');
435
+ clickTip('#wipe', Int`Wipe from ${osName()} all personal data and source files for this app.`, async event => {
436
+ await networkPromise?.then(contact => contact.disconnect());
437
+ localStorage.clear();
438
+ await caches.keys().then(keys => Promise.all(keys.map(key => caches.delete(key))));
439
+ await navigator.serviceWorker.getRegistrations().then(registrations => Promise.all(registrations.map(r => r.unregister())));
440
+ window.location.replace('about/index.html');
441
+ });
442
+ }
443
+
408
444
  tooltip('#learnMore', Int`Click to see more information in another tab about what you can do with this app and how it is resistant to tracking, censorship, and takedown.`);
409
- tooltip('#aboutAnyone2', Int`Click to see the source code and documentation for serving copies of this app in another tab.`);
445
+ tooltip('#learnNotification', Int`Click to learn about receiving operating system notifications.`);
446
+ tooltip('#learnIdentity', Int`Click to learn about how you can choose to label yourself and how to recognize other users.`);
447
+ tooltip('#learnPWA', Int`Click to learn about installation, updates, and removal.`);
410
448
 
411
449
  initialize(false);
@@ -3,6 +3,7 @@ import * as L from 'leaflet';
3
3
  import { Int } from './translations.js';
4
4
  import { consume } from './display.js';
5
5
  import { Agent } from './agent.js';
6
+ import { networkPromise, closeAll } from './main.js';
6
7
  import { P2PWebNetwork } from './p2pWebNetwork.js';
7
8
  import { Alert, go } from './alert.js';
8
9
  import { resetInactivityTimer, tooltip } from './main.js';
@@ -31,9 +32,13 @@ export function showMessage(message, type = 'loading', errorObject) { // Show lo
31
32
  }
32
33
  }
33
34
 
35
+ function flashElement(selector) {
36
+ const element = document.querySelector(selector);
37
+ setTimeout(() => element.style.animation = 'pulse20border ease 2s 1', 100);
38
+ }
39
+
34
40
  let yourLocation; // marker
35
41
  let lastLatitude, lastLongitude;
36
-
37
42
  export function updateLocation(lat, lng, zoom, positionLabel) { // initMap if necessary, and set our position.
38
43
  //console.log('updateLocation', lat, lng, map, yourLocation);
39
44
  // Can't call getCurrentPosition while watching. So set it here for use in recenterMap.
@@ -47,15 +52,16 @@ export function updateLocation(lat, lng, zoom, positionLabel) { // initMap if ne
47
52
  const params = url.searchParams;
48
53
  const tags = params.get('tags');
49
54
  const tagsArray = tags?.split(',') || [];
55
+ const highlight = params.get('highlight');
50
56
  tagsArray.forEach(tag => Hashtags.add(decodeURIComponent(tag)));
51
57
  Hashtags.onchange({resetSubscriptions: false}); // Too early to subscribe, but will be done during initialization.
52
58
  go({lat: params.get('lat'), lng: params.get('lng'), zoom: params.get('z'), alert: params.get('alert')});
53
59
  // We don't need the query parameters now. Get rid of them. They're annoying.
54
60
  if (params.size > 0) {
55
- ['tags', 'lat', 'lng', 'z', 'alert'].forEach(key => params.delete(key));
61
+ ['tags', 'lat', 'lng', 'z', 'alert', 'highlight'].forEach(key => params.delete(key));
56
62
  history.replaceState(null, '', url);
57
63
  }
58
-
64
+ if (highlight) flashElement(highlight);
59
65
  return;
60
66
  }
61
67
  // Otherwise just update the yourLocation marker if appropriate (and not update zoom).
@@ -73,7 +79,7 @@ export function updateLocation(lat, lng, zoom, positionLabel) { // initMap if ne
73
79
 
74
80
  export function recenterMap(event) {
75
81
  consume(event);
76
- Alert.closePopup();
82
+ closeAll();
77
83
  const latLng = [lastLatitude, lastLongitude];
78
84
  map.flyTo(latLng);
79
85
  }
@@ -142,9 +148,10 @@ export function initMap(lat, lng, zoom, positionLabel) { // Set up appropriate z
142
148
  resetInactivityTimer();
143
149
  map.closePopup(yourLocation.getPopup());
144
150
  });
145
- map.on('moveend', () => {
146
- Alert.updateSubscriptions();
151
+ map.on('moveend', async () => {
152
+ await networkPromise;
147
153
  updateLocation(lastLatitude, lastLongitude); // Might now be within map.
154
+ Alert.updateSubscriptions();
148
155
  });
149
156
 
150
157
  // Add click event to note position
@@ -25,6 +25,8 @@ export class P2PWebNetwork {
25
25
  static setSessionLocation = resolveSessionLocation;
26
26
  static sessionLocation = sessionLocationPromise;
27
27
  static async create({infoLogger = console.log, debugLogger,
28
+ // Pass a Storage object to clear peristed push subscriptions, and persist current ones for next session.
29
+ pushPersistor = null,
28
30
  location = this.sessionLocation,
29
31
  bridgeUrl = (globalThis.location && new URL(globalThis.location).searchParams.get('bridge')) ||
30
32
  globalThis.process?.env.BRIDGE_URL ||
@@ -47,7 +49,7 @@ export class P2PWebNetwork {
47
49
  });
48
50
  infoLogger ||= null;
49
51
  debugLogger ||= null;
50
- Object.assign(network, {infoLogger, debugLogger, disconnector: disconnect, transport, nodeIdentity, peer});
52
+ Object.assign(network, {infoLogger, debugLogger, disconnector: disconnect, transport, nodeIdentity, peer, pushPersistor});
51
53
 
52
54
  network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(location.lat, location.lng).toString(16)}.`);
53
55
  peer.onError(error => {
@@ -60,9 +62,30 @@ export class P2PWebNetwork {
60
62
  peer.onLog('error', (...rest) => network.info('ERROR', ...rest));
61
63
  const { peers, ms } = status;
62
64
  network.info(`Connected ${peers} connections through ${bridgeUrl} in ${ms.toLocaleString()} ms.`);
65
+ await network.resetPersisted();
63
66
  network.attached(network);
64
67
  return network;
65
68
  }
69
+ static pushPersistKey = 'lastPushed';
70
+ currentTopics = new Set();
71
+ pushPersist() { // Add/remove topics
72
+ if (!this.pushPersistor) return;
73
+ this.pushPersistor.setItem(this.constructor.pushPersistKey, JSON.stringify([this.nodeIdentity.id, ...this.currentTopics]));
74
+ }
75
+ async resetPersisted() { // Clears any sticky subscriptions in network and for browser's pushManager.
76
+ const {pushPersistor, peer} = this;
77
+ const lastPushed = pushPersistor && pushPersistor.getItem(this.constructor.pushPersistKey);
78
+ if (lastPushed) {
79
+ const [pushId, ...topics] = JSON.parse(lastPushed);
80
+ await Promise.all([
81
+ navigator.serviceWorker.ready
82
+ .then(registration => registration.pushManager.getSubscription())
83
+ .then(subscription => subscription?.unsubscribe()),
84
+ ...topics.map(topic => peer.unsub(topic, {pushId}))
85
+ ]);
86
+ this.pushPersist();
87
+ }
88
+ }
66
89
 
67
90
  async disconnect(debugLogger = this.debugLogger) { // Politely close network connection.
68
91
  const health = this.peer.health();
@@ -179,7 +202,11 @@ export class P2PWebNetwork {
179
202
  // The methods publish/subscribe map from the original civildefense-over-kdht API to Axona, and could be rewritten in the apps.
180
203
  // But since we needed this class anyway, it was easiest to retain them.
181
204
  // Besides, I don't like to see abbreviations in API names.
182
- async subscribe({eventName, region, owner, since = 'all', handler}) { // Assign handler for eventName, or remove any handler if falsy.
205
+ async subscribe({eventName, region, owner, since = 'all',
206
+ handler, // If truthy, associates the handler with topic. If falsy, removes all such handlers from topic.
207
+ pushData, // If handler and truthy (a browser subscription object extended with keys), create a sticky subscription with that.
208
+ pushId, // If no handler and truthy (a previous session nodeId), then cancel that session's sticky subscription.
209
+ pushPersist = this.pushPersistor}) { // If non-null, call this.pushPersist to save sticky data for undoing next session. (See lastPushed in create().)
183
210
  await this.attachment;
184
211
  const topic = {region, name: eventName};
185
212
  if (owner) topic.owner = owner;
@@ -194,9 +221,15 @@ export class P2PWebNetwork {
194
221
  }
195
222
  handler({...message, agent: signerPubkey, tag: msgId, topic, ts});
196
223
  };
197
- await this.peer.sub(topic, callback, {since});
224
+ const result = await this.peer.sub(topic, callback, {since, pushData});
225
+ this.currentTopics.add(topic); // topic rather than result.topicId. See https://github.com/axona-net/axona-protocol/issues/64
226
+ if (pushPersist) this?.pushPersist();
227
+ return result;
198
228
  } else {
199
- await this.peer.unsub(topic, {});
229
+ const result = this.peer.unsub(topic, {pushId}); // See ticket ref'd above, altough topicId will have to be derived in this case.
230
+ this.currentTopics.delete(topic);
231
+ if (pushPersist) this?.pushPersist();
232
+ return result;
200
233
  }
201
234
  }
202
235
  static currentPublishIdentity = null;
@@ -139,9 +139,9 @@ if (dht < 1) {
139
139
  disconnect();
140
140
  },
141
141
  async sub(topic, handler, options) {
142
- const result = await send('subscribe', topic, nodeTag, options);
143
- handlers[result.id] = handler;
144
- return result;
142
+ const { topicName, topicId, id, pushPubkey } = await send('subscribe', topic, nodeTag, options);
143
+ handlers[id] = handler;
144
+ return {topicName, topicId, id, pushPubkey, addPushData: data => send('track', topicName, nodeTag, data)};
145
145
  },
146
146
  async unsub(topic, options) {
147
147
  const result = await send('unsubscribe', topic, nodeTag, options);