@yz-social/civildefense.io 4.5.28 → 5.0.0

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.
@@ -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.refreshPushSubscriptions();
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;
@@ -218,6 +226,7 @@ export let positionWatch;
218
226
  let subscribeOneShot;
219
227
  const goodPositionLabel = Int`Your Location`;
220
228
 
229
+ export let lastCells = null;
221
230
  function initializeGeolocation(subscribe = false) { // Arrange to constantly updateLocation, but:
222
231
  // Arrange to constantly updateLocation when geolocation updates (or a default init if that's not possible).
223
232
  // Once updateLocation has been called, updateSubscriptions() if the subscribe arg is true.
@@ -248,6 +257,8 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
248
257
  zoom = 13;
249
258
  [lat, lng] = [37.7749, -122.4194]; // San Fransisco
250
259
  }
260
+ } else {
261
+ lastCells = getSmallestCellSurrounding(lat, lng);
251
262
  }
252
263
  //console.log('initializeGeolocation updateLocation');
253
264
  updateLocation(lat, lng, zoom, positionLabel);
@@ -276,7 +287,7 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
276
287
  delay(2e3).then(() => initMap());
277
288
  } else {
278
289
  showMessage(Int`Unable to get location.`, 'error', error);
279
- delay(4e3).then(() => initializeGeolocation(subscribe));
290
+ delay(2e3).then(() => initMap());
280
291
  }
281
292
  } else {
282
293
  showMessage(Int`No network connection.`, 'error');
@@ -320,7 +331,8 @@ async function initialize(event) { // Ensure there is a network promise and map,
320
331
  const {promise, resolve} = Promise.withResolvers();
321
332
  networkPromise = promise;
322
333
  console.log('Creating node.');
323
- resolve(P2PWebNetwork.create({}));
334
+ Alert.clearPushData();
335
+ resolve(await P2PWebNetwork.create({pushPersistor: postServiceMessage && localStorage}));
324
336
  networkPromise.then(contact => {
325
337
  globalThis.contact = contact; // For debugging.
326
338
  // On leaving, we would like to copy stored data and politely say 'bye' (so others can clean up their connections). Alas:
@@ -366,11 +378,18 @@ async function initialize(event) { // Ensure there is a network promise and map,
366
378
  }
367
379
  document.addEventListener('visibilitychange', initialize);
368
380
  window.addEventListener('online', initialize);
381
+ window.addEventListener("appinstalled", async () => {
382
+ // It turns out that the same browser that support this push the EXISTING browsing context into their own frame,
383
+ // rather than starting fresh. (Which makes some sense for having this event fire.)
384
+ // In such cases, we need to re-initialize stuff. We could go point by point, but for now, just hit everything.
385
+ location.reload();
386
+ });
369
387
 
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;
388
+ const titleLabel = document.querySelector('#titleLabel');
389
+ titleLabel.textContent = Int([titleLabel.textContent]);
390
+ const titleHostname = document.querySelector('#titleHostname');
391
+ titleHostname.textContent = ` @${location.hostname}`;
392
+ document.querySelector('head > title').innerHTML = titleLabel.textContent + titleHostname.textContent;
374
393
  // Set up text for the browser language.
375
394
  function initText(selector, content = selector) {
376
395
  const element = document.querySelector(selector);
@@ -380,10 +399,6 @@ function initText(selector, content = selector) {
380
399
  initText('#aboutReport');
381
400
  initText('#aboutShared');
382
401
  initText('#aboutFade');
383
- initText('#aboutAnyone1');
384
- initText('#aboutAnyone2');
385
- initText('#aboutAnyone3');
386
- initText('#learnMore');
387
402
  initText('#version');
388
403
  initText('#checkForUpdates');
389
404
  initText('#downloadUpdates');
@@ -398,14 +413,33 @@ initText('#describePrivate2');
398
413
  initText('#describePublic');
399
414
  initText('#describeSystem');
400
415
  initText('#pickLabels');
401
- initText('#wipe');
402
416
  initText('.firstConversation .teach.correspondent');
403
417
  initText('.firstPublish .teach.correspondent');
404
418
  initText('.teach.share');
405
419
  initText('.teach.attach');
406
420
  initText('.teach.changeHashtag');
407
421
 
422
+ clickTip('#aboutButton', Int`Information about this app, and options to change notifications or how you appear to others.`, event => { // open about
423
+ closeAll();
424
+ openAbout(event);
425
+ });
426
+
427
+ if (isStandalone()) {
428
+ document.querySelector('#wipe').textContent = Int`Installed`;
429
+ } else {
430
+ initText('#wipe');
431
+ clickTip('#wipe', Int`Wipe from ${osName()} all personal data and source files for this app.`, async event => {
432
+ await networkPromise?.then(contact => contact.disconnect());
433
+ localStorage.clear();
434
+ await caches.keys().then(keys => Promise.all(keys.map(key => caches.delete(key))));
435
+ await navigator.serviceWorker.getRegistrations().then(registrations => Promise.all(registrations.map(r => r.unregister())));
436
+ window.location.replace('about/index.html');
437
+ });
438
+ }
439
+
408
440
  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.`);
441
+ tooltip('#learnNotification', Int`Click to learn about receiving operating system notifications.`);
442
+ tooltip('#learnIdentity', Int`Click to learn about how you can choose to label yourself and how to recognize other users.`);
443
+ tooltip('#learnPWA', Int`Click to learn about installation, updates, and removal.`);
410
444
 
411
445
  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';
@@ -73,7 +74,7 @@ export function updateLocation(lat, lng, zoom, positionLabel) { // initMap if ne
73
74
 
74
75
  export function recenterMap(event) {
75
76
  consume(event);
76
- Alert.closePopup();
77
+ closeAll();
77
78
  const latLng = [lastLatitude, lastLongitude];
78
79
  map.flyTo(latLng);
79
80
  }
@@ -142,9 +143,10 @@ export function initMap(lat, lng, zoom, positionLabel) { // Set up appropriate z
142
143
  resetInactivityTimer();
143
144
  map.closePopup(yourLocation.getPopup());
144
145
  });
145
- map.on('moveend', () => {
146
- Alert.updateSubscriptions();
146
+ map.on('moveend', async () => {
147
+ await networkPromise;
147
148
  updateLocation(lastLatitude, lastLongitude); // Might now be within map.
149
+ Alert.updateSubscriptions();
148
150
  });
149
151
 
150
152
  // 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);