@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,14 +1,15 @@
1
1
  import * as L from 'leaflet';
2
2
  import { P2PWebNetwork } from './p2pWebNetwork.js';
3
+ import { generateVapidKeys } from './browser-push.js';
3
4
  import { Int } from './translations.js';
4
5
  import { map, trackMap, showMessage } from './map.js';
5
- import { networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, getText, openAbout, delay, osName } from './main.js';
6
+ import { closeAll, postServiceMessage, networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, getText, openAbout, delay, osName, lastCells } from './main.js';
6
7
  import { consume } from './display.js';
7
8
  import { Hashtags } from './hashtags.js';
8
9
  import { Agent } from './agent.js';
9
10
  import { Conversation, Reply } from './conversation.js';
10
11
  import { alertTopic, topicRegion, cellHex, topicCell, topicTag } from './versions.js';
11
- import { getContainingCells, getSmallestCellId, getSubdivision, findCoverCellsByMinMaxLatLng, cellContains, pointFromLatLng, cellFromCellID } from './s2.js';
12
+ import { getContainingCells, getSmallestCellId, getSubdivision, findCoverCellsByMinMaxLatLng, cellContains, pointFromLatLng, cellFromCellID, getCellCorners } from './s2.js';
12
13
  const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
13
14
 
14
15
 
@@ -43,7 +44,7 @@ export async function share(properties) { // Invoke platform share API on prope
43
44
  }
44
45
  }
45
46
  if (!properties.files) {
46
- Alert.closePopup();
47
+ closeAll();
47
48
  await delay(500); // Allow popup time to close. It doesn't render well because of the web component style sheets.
48
49
  const target = document.getElementById('mapCapture'); // For capturing a screen shot.
49
50
  const icon = target.lastElementChild;
@@ -65,6 +66,7 @@ export async function share(properties) { // Invoke platform share API on prope
65
66
  const ttl = 24 * 60 * 60e3; // 24 hours
66
67
  let openOnReceive = null;
67
68
  export function go({lat = null, lng = null, zoom = null, alert = null}) { // Go to specified location (if any) and open marker (if any).
69
+ closeAll();
68
70
  if (lat !== null && lng !== null) {
69
71
  lat = parseFloat(lat);
70
72
  lng = parseFloat(lng);
@@ -91,7 +93,7 @@ class AlertReply extends Reply {
91
93
  // and contents, and then have ensureContent() arrange in its delayed followup to replace the placeholder when the promise
92
94
  // resolves.
93
95
  await super.initialize(properties);
94
- const {container, agent, issuedTime, payload} = properties;
96
+ const {container, agent, issuedTime, payload, alert} = properties;
95
97
  const {file:attachmentTopic} = payload;
96
98
  if (attachmentTopic) {
97
99
  const contact = await networkPromise;
@@ -99,13 +101,30 @@ class AlertReply extends Reply {
99
101
  const {dataURL:file, name, msgIds} = await contact.assembleChunkedDataURL(attachmentTopic);
100
102
  Object.assign(payload, {file, name, attachmentTopic, msgIds});
101
103
  }
102
- container.showNotification({agent, issuedTime, body: payload.message || payload.name || payload});
104
+ this.showNotification();
103
105
  return this;
104
106
  }
107
+ get body() {
108
+ const {payload} = this;
109
+ const body = payload.message || payload.name || payload;
110
+ return body;
111
+ }
105
112
  update() { } // TODO: are we really getting multiple reply events for the same data?
113
+ // We coalesce notifications by tags. If we did not, it would be nice to remove the
114
+ // notification if the reply is deleted.
115
+ // Since we do coelesce, that's largely unnecessary. However, there is still the case
116
+ // of the poster deleting the currently last reply. It would be nice to restore the
117
+ // previous message in the notification body. We COULD do that in a delete() method
118
+ // here, but that wouldn't work in a service worker for offline notifications, as it does
119
+ // not have access to the list of previous messages.
120
+ // So... for now, we do not remove alerts at all, because in the one case that it matters,
121
+ // it could result in a user becoming unaware of the remaining messages.
122
+ showNotification({force = false} = {}) {
123
+ const {container, agent, issuedTime, body, alert} = this;
124
+ container.showNotification({agent, issuedTime, body, alert, force});
125
+ }
106
126
  }
107
127
 
108
- import { s2 } from 's2js';
109
128
  export class Alert extends Conversation { // A wrapper around L.marker
110
129
  // For each hashtag, we subscribe to a set of non-overlapping cells at varying S2 levels that cover the current map.
111
130
  // An Alert is made when a subscription handler fires, and it keeps information that is (mostly) true regardless of
@@ -150,22 +169,21 @@ export class Alert extends Conversation { // A wrapper around L.marker
150
169
  const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
151
170
  const region = P2PWebNetwork.regionCode(lat, lng);
152
171
  hashtag = Hashtags.add(hashtag); // We already have it and are subscribing, but this updates our extended form if needed.
153
- super.initialize({payload, hashtag, tag, agent, lat, lng, issuedTime, originalPosting, ...rest});
172
+ super.initialize({payload, hashtag, tag, agent, lat, lng, region, issuedTime, originalPosting, ...rest});
154
173
  if (aggregate) {
155
174
  // Destroy existing eventName markers and add their positions to the aggregate we are creating.
156
175
  this.becomeAggregate(eventName);
157
176
  this.constructor.clearEventMarkers(eventName, aggregate);
158
177
  } else {
159
178
  this.noteEventName(eventName);
160
- marker.bindPopup('', {className: 'alert'}).on('popupopen', event => this.ensureContent(event.popup));
179
+ marker.bindPopup('', {className: 'alert'})
180
+ .on('popupopen', event => this.ensureContent(event.popup))
181
+ .on('popupclose', () => this.closeTeach());
161
182
  tooltip(marker.getElement(), Int`Show conversation for this ${hashtag} alert.`);
162
- if (tag === openOnReceive) { // Bug! How can we handle URLs to an alert that has been aggregated?
183
+ if (tag === openOnReceive) {
163
184
  openOnReceive = false;
164
185
  this.openPopup();
165
186
  }
166
- networkPromise.then(async contact => { // Subscribe to replies to this tag, now that we have an alert for them to go to.
167
- contact.subscribe({eventName: tag, region, handler: data => this.ensure(data)});
168
- });
169
187
  }
170
188
  }
171
189
  const alert = aggregate || this;
@@ -173,6 +191,17 @@ export class Alert extends Conversation { // A wrapper around L.marker
173
191
  alert.showNotification({agent, issuedTime});
174
192
  return keep;
175
193
  }
194
+ susbscribed = false;
195
+ async ensureRepliesSubscribed(unsubscribe = false) { // Listen for replies to this alert.
196
+ const {tag, region, aggregate, subscribed} = this;
197
+ console.log('subscribeReplies', {tag, region, aggregate, subscribed, unsubscribe});
198
+ if (aggregate) return;
199
+ this.subscribed = true;
200
+ const contact = await networkPromise;
201
+ if (unsubscribe) await contact.subscribe({eventName: tag, region, handler: null});
202
+ await contact.subscribe({eventName: tag, region, handler: data => this.ensure(data),
203
+ pushData: await this.constructor.pushData});
204
+ }
176
205
  update({topic, ts, ...rest}) { // Called when handling an existing Conversation. super confirms that nothing immutable has changed.
177
206
  return super.update({...rest}); // topic and ts vary with level, and so must not be part of ensure/update checks.
178
207
  }
@@ -185,7 +214,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
185
214
  this.clearAvatars();
186
215
  const {isAggregate, marker, tag, region} = this;
187
216
  // Unsubscribe from replies.
188
- if (!isAggregate) networkPromise?.then(async contact => contact.subscribe({eventName: tag, region, handler: null}));
217
+ if (!isAggregate) networkPromise?.then(async contact => {
218
+ contact.subscribe({eventName: tag, region, handler: null});
219
+ });
189
220
  this.cellBorder?.removeFrom(map);
190
221
  super.destroy();
191
222
  if (markerDelayMS) {
@@ -196,40 +227,105 @@ export class Alert extends Conversation { // A wrapper around L.marker
196
227
  }
197
228
  marker.removeFrom(map);
198
229
  }
230
+ delete() { // If ensure gets an empty body, cancel any associated notification.
231
+ if (notificationsAllowed()) postServiceMessage('cancelNotification', [this.tag]);
232
+ return super.delete();
233
+ }
199
234
 
200
- static subscriptionQueue = Promise.resolve(); // Serialize updates so they don't overlap each other.
201
- static async updateSubscriptions({newKeys, oldKeys, throttleMS = 20} = {}) { // Update current subscriptions.
202
- // A value of {} passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
203
- return this.subscriptionQueue = this.subscriptionQueue.then(async () => {
204
- oldKeys ||= this.subscriptions;
205
- newKeys ||= this.subscriptionFromMap();
235
+ static subscriptionQueue = []; // Queue of pending updateSubscriptions reasons.
236
+ static updateSubscriptions(reason = 'map') { // Update current subscriptions for the given purpose:
237
+ // newNode: Supply an empty oldSubscriptions.
238
+ // stickyChange: Make sticky subscriptions match a new state.
239
+ // map: Make fewest changes for new map display.
240
+ //
241
+ // The effects must be serialized, but a pending map update can always be skipped for something after it.
242
+ // We don't want to stack up such changes, because the update can take several seconds on a bad network, during
243
+ // which the user might just keep futzing with the map.
244
+ const queue = this.subscriptionQueue;
245
+ const inProgress = queue.length;
246
+ queue.push(reason);
247
+ if (!inProgress) this.updatePendingSubscriptions();
248
+ }
249
+ static async updatePendingSubscriptions() { // Process the queue.
250
+ const contact = await networkPromise;
251
+ if (!contact) {
252
+ console.warn("No network through which to subscribe.");
253
+ return null; }
206
254
 
207
- if (!newKeys) return; // e.g., wacky computation. Don't change anything.
208
- const contact = await networkPromise;
209
- const dropped = [], added = [];
210
- if (!contact) { console.warn("No network through which to subscribe."); return; } // Does this ever happen? Why?
211
- this.subscriptions = newKeys; // Before subscribing.
212
- const subscribe = async (eventName, handler) => {
213
- if (!eventName) console.log('sub to no eventName', {oldKeys, newKeys, dropped, added, handler});
214
- const region = topicRegion(eventName);
215
- if (handler) Agent.current?.trackPublicChanges(region); // Background. No need to await.
216
- await contact.subscribe({eventName, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
217
- };
218
- for (const key in newKeys) oldKeys.hasOwnProperty(key) || added.push(key);
219
- for (const key in oldKeys) newKeys.hasOwnProperty(key) || dropped.push(key);
220
- console.log('updating subscriptions', {added, dropped, newKeys, oldKeys});
221
-
222
- // Before subscribing, as that that may bring in an alert with the same tag as one being cleared.
223
- if (this.aggregateLimit) {
224
- // TODO: more efficient way?
225
- new Set(added.map(topicTag)).forEach(tag => {
226
- const hasTag = topicName => topicTag(topicName) === tag;
227
- this.transferOrClearEventMarkers(added.filter(hasTag), dropped.filter(hasTag), newKeys, oldKeys);
228
- });
255
+ const queue = this.subscriptionQueue;
256
+ const reason = queue[0];
257
+ let newKeys, oldKeys;
258
+ if (reason === 'stickyChange') {
259
+ await contact.resetPersisted();
260
+ }
261
+ if (reason !== 'map') {
262
+ oldKeys = {};
263
+ // Update replies subscriptions for alerts that have already been opened.
264
+ for (const alert of this.items) {
265
+ if (alert.subscribed) await alert.ensureRepliesSubscribed(true);
229
266
  }
267
+ } else if (queue.length > 1) { // Skip this map and just go on to whatever is pending.
268
+ queue.shift();
269
+ return this.updatePendingSubscriptions();
270
+ }
271
+
272
+ // Now complete this subscription update in full, before considering any others.
273
+ oldKeys ||= this.subscriptions;
274
+ newKeys ||= this.subscriptionFromMap();
230
275
 
231
- for (const key of added) await subscribe(key, data => Alert.ensure(data));
232
- for (const key of dropped) await subscribe(key, null);
276
+ const dropped = [], added = [];
277
+ this.subscriptions = newKeys; // Before subscribing.
278
+ const subscribe = async (eventName, handler) => {
279
+ if (!eventName) console.log('sub to no eventName', {oldKeys, newKeys, dropped, added, handler});
280
+ const region = topicRegion(eventName);
281
+ const pushData = handler && await this.pushData;
282
+ if (handler) Agent.current?.trackPublicChanges(region); // Background. No need to await.
283
+ return contact.subscribe({eventName, region, handler, pushData, pushPersist: null});
284
+ };
285
+ for (const key in newKeys) oldKeys.hasOwnProperty(key) || added.push(key);
286
+ for (const key in oldKeys) newKeys.hasOwnProperty(key) || dropped.push(key);
287
+ console.log('updating subscriptions', {reason, added, dropped, newKeys, oldKeys});
288
+
289
+ // Before subscribing, as that that may bring in an alert with the same tag as one being cleared.
290
+ if (this.aggregateLimit) {
291
+ // TODO: more efficient way?
292
+ new Set(added.map(topicTag)).forEach(tag => {
293
+ const hasTag = topicName => topicTag(topicName) === tag;
294
+ this.transferOrClearEventMarkers(added.filter(hasTag), dropped.filter(hasTag), newKeys, oldKeys);
295
+ });
296
+ }
297
+ // Subtle: await throttle time between initiating network request, but do not wait for each request to complete before starting the next.
298
+ // Do wait for all to complete.
299
+ const throttleMS = 20;
300
+ const promises = [];
301
+ for (const key of added) {
302
+ promises.push(subscribe(key, data => Alert.ensure(data)));
303
+ if (throttleMS) await P2PWebNetwork.delay(throttleMS);
304
+ }
305
+ for (const key of dropped) {
306
+ promises.push(subscribe(key, null));
307
+ if (throttleMS) await P2PWebNetwork.delay(throttleMS);
308
+ }
309
+ contact.pushPersist();
310
+ await Promise.all(promises);
311
+ queue.shift();
312
+ //console.log('updated', promises.length);
313
+ if (queue.length) return this.updatePendingSubscriptions(); // Handle anything now pending.
314
+ return null;
315
+ }
316
+ static clearPushData() { // Force new push subscription when next asked. Not needed at startup, but when creating a new node.
317
+ this._pushData = null; // Otherwise, new subscriptions with the old data will be rejected by the service as being for an unsubscribed client.
318
+ }
319
+ static get pushData() {
320
+ if (!notificationsAllowed()) return null;
321
+ if (this._pushData) return this._pushData;
322
+ return this._pushData = new Promise(async resolve => {
323
+ const {publicKey:applicationServerKey, privateKey:applicationId} = await generateVapidKeys();
324
+ const registration = await navigator.serviceWorker.ready;
325
+ const data = await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey});
326
+ const json = data.toJSON();
327
+ //console.log('pushData', {registration, data, json});
328
+ resolve({...json, applicationId, applicationServerKey});
233
329
  });
234
330
  }
235
331
 
@@ -254,15 +350,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
254
350
  this.eventName = eventName;
255
351
  if (!this.isAggregate) return;
256
352
  this.cellBorder?.removeFrom(map);
257
- const cell = cellFromCellID(topicCell(eventName));
258
- const radiansToDegrees = 180 / Math.PI;
259
- const corners = Array.from({ length: 4 }, (_, i) => {
260
- const point = cell.vertex(i);
261
- const latLng = s2.LatLng.fromPoint(point);
262
- return [latLng.lat * radiansToDegrees, latLng.lng * radiansToDegrees];
263
- });
264
- const border = this.cellBorder = L.polygon(corners, {color: this.constructor.md_sys_color_secondary, fillOpacity: 0.1});
353
+ this.cellBorder = this.constructor.displayCell(topicCell(eventName));
354
+ }
355
+ static displayCell(cell, color = this.md_sys_color_secondary) { // And return cell for caching/removal.
356
+ // Draw polygon border from cell corners.
357
+ const corners = getCellCorners(cell);
358
+ const border = L.polygon(corners, {color, fillOpacity: 0.1});
265
359
  border.addTo(map);
360
+ return border;
266
361
  }
267
362
  static md_sys_color_secondary = '#BD2E2F';
268
363
  static forEachAlertOf(eventName, callback, alerts = this.items) { // Apply callback to each of alerts matching eventName.
@@ -394,7 +489,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
394
489
 
395
490
  // We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
396
491
  static lastLevel9Cell = null; // S2 level 9 cells average a radius of about 10km ~ 6.5 miles.
397
- static subscriptionFromMap() { // Generate new subscriptions list ({eventName => count}) for current map bounds.
492
+ static shownCells = false; // Empty array for stashing debug polygons, else falsy.
493
+ static subscriptionFromMap() { // Generate new subscriptions list ({eventName => count}) for current map bounds,
494
+ // and additionally, a 1x1 degree box around the user's last known position, if any, regardless of map bounds.
398
495
  const center = map.getCenter();
399
496
  const bounds = map.getBounds();
400
497
  const northEast = bounds.getNorthEast();
@@ -407,7 +504,15 @@ export class Alert extends Conversation { // A wrapper around L.marker
407
504
  minLng: southWest.lng,
408
505
  maxLng: northEast.lng
409
506
  });
410
- if (!newCells) return null;
507
+ if (!newCells) return {};
508
+ if (this.shownCells) { // debugging
509
+ this.shownCells.forEach(polygon => polygon.removeFrom(map));
510
+ const mapPolys = newCells.map(cell => this.displayCell(cell));
511
+ const geoPolys = lastCells.map(cell => this.displayCell(cell, 'blue'));
512
+ this.shownCells = [...mapPolys, ...geoPolys];
513
+ }
514
+ // Alert.ensure will not pub multiple markers/notifications for the same alert, but let us not double subscriptions.
515
+ if (lastCells) lastCells.forEach(cell => newCells.includes(cell) || newCells.push(cell));
411
516
  const newKeys = {};
412
517
  newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => { // Populate count with existing count (exctly carried over cell sizes), else 0.
413
518
  const eventName = alertTopic(cell, hash);
@@ -437,7 +542,6 @@ export class Alert extends Conversation { // A wrapper around L.marker
437
542
  payload = {lat, lng, originalPosting}, // If payload is null (cancels tag), lat & lng are still used to generate eventNames.
438
543
  cancel = undefined, // First unpublish the specified data, if any. Complicated default.
439
544
  issuedTime = Date.now(), tag,
440
- throttleMS = 0,
441
545
  ...rest
442
546
  }) {
443
547
  // We call all the publishing at once and return tag, without waiting for each to occur.
@@ -457,14 +561,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
457
561
 
458
562
  const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
459
563
  let oldCells = null, oldHash, oldTag = null; // Recorded for logging, below.
460
- let lastFillIn;
564
+ let lastFillIn = {lat, lng, hashtag, issuedTime};
461
565
  if (payload) {
462
- lastFillIn = {lat, lng, hashtag, issuedTime};
463
566
  this.lastPublished.push(lastFillIn); // Capture the added data.
464
567
  const periodStart = Date.now() - (this.maxPublish * 60e3); // maxPublish minutes ago.
465
568
  this.lastPublished = this.lastPublished.filter(past => past.issuedTime >= periodStart);
466
569
  if (cancel === undefined && this.lastPublished.length > this.maxPublish) { // Unless specified otherwise, cancel oldest over maxPublish.
467
- showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.`, 'instructions');
570
+ setTimeout(() => showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.`),
571
+ 3e3); // If immediate, it will get covered by instructions on the new alert.
468
572
  cancel = this.lastPublished.shift();
469
573
  }
470
574
  }
@@ -473,36 +577,15 @@ export class Alert extends Conversation { // A wrapper around L.marker
473
577
  oldCells = getContainingCells(lat, lng);
474
578
  oldHash = hashtag; oldTag = tag;
475
579
  const region = P2PWebNetwork.regionCode(lat, lng);
476
- for (const cell of oldCells) {
477
- const eventName = alertTopic(cell, hashtag);
478
- // Note: we cannot unpublish replies by others, but they expire after a while anyway.
479
- await contact.publish({eventName, region, killTag: tag, payload: null});
480
- throttleMS && await P2PWebNetwork.delay(throttleMS);
481
- }
580
+ await Promise.all(oldCells.map(cell => contact.publish({eventName: alertTopic(cell, hashtag), region, killTag: tag, payload: null})));
482
581
  }
483
582
 
484
583
  const region = P2PWebNetwork.regionCode(lat, lng);
485
- for (const eventName of eventNames) {
486
- if (payload) {
487
- // The Axona message will be {hashtag, issuedTime, payload:{lat, lng, originalPosting}}
488
- // and when combined with the publisher's authorId will be unique to this user/time/hashtag,
489
- // and yet the same for each of the individual publications at the different s2 scales.
490
- const msgId = await contact.publish({eventName, region, payload, issuedTime, hashtag, ...rest});
491
- if (tag && tag !== msgId) throw new Error(`msgId is drifting: ${tag} => ${msgId}`);
492
- tag = msgId;
493
- if (lastFillIn) {
494
- lastFillIn.tag = tag;
495
- lastFillIn = null;
496
- }
497
- } else {
498
- await contact.publish({eventName, region, killTag: tag, payload: null});
499
- throttleMS && await P2PWebNetwork.delay(throttleMS);
500
- }
501
- }
502
- if (!payload) {
503
- const index = this.lastPublished.findIndex(past => past.tag === tag);
504
- if (index >= 0) this.lastPublished.splice(index, 1);
505
- }
584
+ const pubs = await Promise.all(eventNames.map(eventName =>
585
+ contact.publish({eventName, region, killTag: tag, payload: tag ? null : payload, issuedTime, hashtag, ...rest})));
586
+ if (payload) tag = lastFillIn.tag = pubs[0];
587
+ else this.lastPublished = this.lastPublished.filter(past => past.tag !== tag);
588
+
506
589
  console.log('Published', {cells, n: cells.length, region, hashtag, tag, payload, oldCells, oldHash, oldTag});
507
590
  return tag;
508
591
  } finally {
@@ -516,10 +599,11 @@ export class Alert extends Conversation { // A wrapper around L.marker
516
599
 
517
600
  static noMessage = Int`No additional information.`;
518
601
  static closePopup() { // Close any open popup.
519
- map.closePopup();
602
+ map?.closePopup();
520
603
  Hashtags.closeSelector();
521
604
  }
522
- static openPopup(alertTag) { // Open the marker specified by tag.
605
+ static async openPopup(alertTag) { // Open the marker specified by tag.
606
+ await networkPromise;
523
607
  const wrapper = this.getItem(alertTag);
524
608
  wrapper?.openPopup() || (openOnReceive = alertTag);
525
609
  }
@@ -561,10 +645,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
561
645
  }
562
646
 
563
647
  needsRedisplay = true;
564
- ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
648
+ async ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
649
+ resetInactivityTimer();
565
650
  if (!popup) return;
566
651
  if (!popup.isOpen()) return;
567
652
  this.logAlert();
653
+ if (!this.subscribed) await this.ensureRepliesSubscribed();
568
654
  if (!this.needsRedisplay) {
569
655
  this.initializeHandlers(popup);
570
656
  return;
@@ -575,18 +661,27 @@ export class Alert extends Conversation { // A wrapper around L.marker
575
661
  let content = this.formatAttribution({agent, issuedTime, originalPosting, hashtag});
576
662
  content += this.formatReplies();
577
663
  popup.setContent(content);
578
- delay(100).then(() => {
579
- this.marker.getPopup().update();
664
+ const onFirstNewPopup = () => {
665
+ const popup = this.marker.getPopup();
666
+ if (!popup) delay(50).then(onFirstNewPopup);
667
+ popup.update();
580
668
  this.initializeHandlers(popup);
581
669
  this.teach('firstConversation');
582
670
  if (Agent.isMine(this.agent)) this.teach('firstPublish');
583
- });
671
+ };
672
+ onFirstNewPopup();
584
673
  }
585
674
  teach(classname) {
586
675
  if (localStorage.getItem(classname)) return;
587
676
  localStorage.setItem(classname, '1');
588
677
  document.body.classList.toggle(classname, true);
678
+ document.querySelectorAll('.teach').forEach(element => element.onclick = () => this.closeTeach());
679
+ }
680
+ closeTeach() { // All of them.
681
+ document.body.classList.toggle('firstConversation', false);
682
+ document.body.classList.toggle('firstPublish', false);
589
683
  }
684
+
590
685
  clearAvatars(popup = this.marker?.getPopup()) {
591
686
  popup?.getElement()?.querySelectorAll('.correspondent[data-tag]')
592
687
  .forEach(element => Agent.ensure({tag: element.dataset.tag}).removeElement(element, 'mixed', element.classList.contains('avatar') ? 'avatar' : 'handle'));
@@ -614,10 +709,10 @@ export class Alert extends Conversation { // A wrapper around L.marker
614
709
  filenameDisplay.textContent = fileChooser.files.length ? (fileChooser.files[0].name || 'camera') : '';
615
710
  };
616
711
  this.initChangeHashtag(popupElement);
712
+ const region = this.region;
617
713
  for (const correspondent of popupElement.querySelectorAll('.correspondent')) {
618
714
  const tag = correspondent.dataset.tag;
619
- const region = P2PWebNetwork.regionCode(this.lat, this.lng);
620
- const agent = Agent.ensure({tag, region: region});
715
+ const agent = Agent.ensure({tag, region});
621
716
  const isAvatar = correspondent.classList.contains('avatar');
622
717
  if (agent.addElement(correspondent, 'mixed', isAvatar ? 'avatar' : 'handle')) {
623
718
  const isMine = Agent.isMine(tag);
@@ -674,7 +769,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
674
769
  `;
675
770
  return `<md-outlined-button class="changeHashtag">${pubtag}</md-outlined-button>`;
676
771
  }
677
- formatAttributionActions({agent, hashtag}) { // Anser div HTML containing: [deleter] sharer [hashtag]
772
+ formatAttributionActions({agent, hashtag}) { // Answer div HTML containing: [deleter] sharer [hashtag]
678
773
  // Where deletere appears if it our reply (no hashtag), and hashtag if present is a button if ours (and otherwise just text).
679
774
  const isOurs = agent === Agent.tag;
680
775
  const deleter = !hashtag && isOurs ? `<md-outlined-icon-button><md-icon class="material-icons">delete_forever</md-icon></md-outlined-icon-button>` : '';
@@ -728,8 +823,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
728
823
  const ringElement = this.startFader('.alert-commented', remaining);
729
824
  if (ringElement) {
730
825
  ringElement.style.display = 'block';
731
- // Restart the pulse animation by setting animationName to something it isn't.
732
- ringElement.style.animationName = ringElement.style.animationName === 'pulse2' ? 'pulse' : 'pulse2';
826
+ ringElement.style.animationName = 'pulse20 ease 2s 1';
733
827
  }
734
828
  }
735
829
  }
@@ -742,9 +836,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
742
836
  event.stopPropagation();
743
837
  const button = event.target;
744
838
  const inputElement = button.parentElement;
745
- let payload = inputElement.value.trim();
746
- const {tag, hashtag, lat, lng} = this;
747
- const region = P2PWebNetwork.regionCode(lat, lng);
839
+ const {tag, hashtag, lat, lng, region} = this;
840
+ let payload = {message: inputElement.value.trim(), lat, lng};
748
841
  const files = inputElement.parentElement.querySelector('input[type="file"]').files;
749
842
  if (!payload && !files.length) return;
750
843
  inputElement.value = '';
@@ -752,15 +845,17 @@ export class Alert extends Conversation { // A wrapper around L.marker
752
845
  const contact = await networkPromise;
753
846
  if (files.length) {
754
847
  const {topic:file, msgIds} = await contact.chunkifyBlob({blob: files[0], region});
755
- payload = {message: payload, file};
848
+ payload.file = file;
756
849
  }
757
- await contact.publish({eventName: tag, region, payload}); // Publish the new reply.
850
+ // Users won't subscribe to tag unless the alert itself has already landed, and that has lat/lng/hashtag.
851
+ // However, these must be included anyway in case the receiving user clicks on an out-of-band
852
+ // notification at a later time, without the alert being on the map at that moment.
853
+ await contact.publish({eventName: tag, region, payload, hashtag, alert: tag}); // Publish the new reply.
758
854
  Agent.current.persistPublicMetadata();
759
855
  }
760
856
  deleteReply(replyElement) {
761
857
  resetInactivityTimer();
762
- const {lat, lng, tag} = this;
763
- const region = P2PWebNetwork.regionCode(lat, lng);
858
+ const {lat, lng, region, tag} = this;
764
859
  const killTag = replyElement.dataset.tag;
765
860
  networkPromise.then(async contact => {
766
861
  // We won't be here unless we are the signer.
@@ -776,34 +871,23 @@ export class Alert extends Conversation { // A wrapper around L.marker
776
871
  }
777
872
  });
778
873
  }
779
- showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
874
+ showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag, force = false}) {
780
875
  // Give OS notification that comes back to here, unless act is us.
781
876
  // All notifications on the same alert (e.g., the post and each reply) have the same tag, so OS can collapse them.
877
+ //console.log('alert showNotification', {lat, lng, hashtag, alert, agent, body, force});
782
878
  if (agent === Agent.tag || !notificationsAllowed()) return;
783
- navigator.serviceWorker.ready.then(registration => {
784
- const timestamp = issuedTime;
785
- const icon = new URL('./images/civil-defense-192.png', location.href).href;
786
- const url = getShareableURL(alert, [hashtag]).href; // For opening page when it has been closed.
787
- const data = {lat, lng, url};
788
- // It appears that on 8/14/26:
789
- // Safari ignores tag/renotify, and ALWAYS tells the user and displays each notification separately, without consolidating by tag.
790
- // Chrome ignores renotify, and ALWAYS consolidates by tag, replacing old body with new, and NEVER renotifies the user (for the same tag).
791
- // So... we could get uniform behavior by skipping the tag, but for now we'll try using it as intended, in case the browsers ever start to comply.
792
- const options = {icon, timestamp, tag: alert, body, data, renotify: true};
793
- console.log('showNotification', hashtag, options);
794
- registration.showNotification(hashtag, options);
795
- });
879
+ postServiceMessage('notify', {lat, lng, issuedTime, hashtag, alert, body, force});
796
880
  }
797
881
  // Each reply element is a DIV.reply with data-tag and data-text attributes that are used in sharing.
798
882
  // It contains an attribution header with controls, zero or one attachments, and then the message text.
799
883
  // If present the attachment will be an A element with download attribute, surrounding either an IMG, A/V player, or an attachment icon followed by the file name.
800
884
  formatReplies() { // Answer HTML for the replies and input box.
801
885
  const { items, agent, originalPosting } = this;
802
- const formatReply = ({tag, payload, ...rest}) => {
886
+ const formatReply = ({tag, payload, hashtag, ...rest}) => {
803
887
  const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
804
888
  let text = message
805
- .replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}" crossorigin="anonymous"></audio>`) // show audio urls as players
806
- .replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}" crossorigin="anonymous"></video>`) // show video urls as players
889
+ .replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}" crossorigin="anonymous"></audio>`) // show audio urls as players
890
+ .replace(/https?:\/\/\S+\.(mp4|mov|webm|m3u8)$/ig, url => `<video controls src="${url}" crossorigin="anonymous"></video>`) // show video urls as players
807
891
  .replace(/(?<!")https?:\/\/\S+/g, url => `<a href="${url}" target="yz.sidebar">${url}</a>`); // show urls as links
808
892
  let attachment = '';
809
893
  if (file?.startsWith?.('data:image')) attachment = `<a href="${file}" download="${name}"><img class="attachment" src="${file}"></img></a>`;
@@ -0,0 +1,56 @@
1
+ function bufferToBase64Url(buffer) { // Like it says.
2
+ const bytes = new Uint8Array(buffer);
3
+ let binary = '';
4
+ for (let i = 0; i < bytes.byteLength; i++) {
5
+ binary += String.fromCharCode(bytes[i]);
6
+ }
7
+ return btoa(binary)
8
+ .replace(/\+/g, '-')
9
+ .replace(/\//g, '_')
10
+ .replace(/=+$/, '');
11
+ }
12
+
13
+ // These are the keys used by sender to authenticate itself to the push service.
14
+ // The pubkey is given by the application to the browser pushManager.subscribe() so that the push-service knows.
15
+ // Then the entity sending the push signs the request that it makes to the push-service.
16
+ //
17
+ // The browser only allows ONE current subscribe in the page, with one such pubkey.
18
+ // I.e., a page cannot have multiple subscriptions to different services simultaneously.
19
+ //
20
+ // In server-sovereign applications, the server makes the key pair only once (for at least the lifetime of an app version),
21
+ // and passes the pubkey to the app.
22
+ //
23
+ // In our client-sovereign applications, each Axon root is essentially its own appserver in that it makes the requests
24
+ // to the push-service. In order to have just one subscription per client, we have the client generate the keypair,
25
+ // use the pubkey in the normal way when makeing the pushManger.subscribe() call, and pass the keys to Axon node for
26
+ // use when pushing to this particular client. It will use other keys when pushing to other clients.
27
+ // (The client still authenticates the pushed envelope in the normal way, just as if it had been delivered directly
28
+ // through the connected Axona network.)
29
+
30
+ export async function generateVapidKeys() {
31
+ // 1. Generate an ECDSA key pair on the P-256 curve
32
+ const keyPair = await window.crypto.subtle.generateKey(
33
+ {
34
+ name: 'ECDSA',
35
+ namedCurve: 'P-256',
36
+ },
37
+ true, // extractable
38
+ ['sign', 'verify']
39
+ );
40
+
41
+ // 2. Export public key in 'raw' format (65 bytes: 0x04 + 32-byte X + 32-byte Y)
42
+ const rawPublicKey = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
43
+ const publicKeyBase64Url = bufferToBase64Url(rawPublicKey);
44
+
45
+ // 3. Export private key in 'jwk' format to extract the 32-byte 'd' parameter
46
+ const jwkPrivateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
47
+
48
+ // The 'd' field in JWK is base64url encoded already; decode it to bytes then re-encode safely
49
+ const privateKeyBytes = Uint8Array.from(atob(jwkPrivateKey.d.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
50
+ const privateKeyBase64Url = bufferToBase64Url(privateKeyBytes);
51
+
52
+ return {
53
+ publicKey: publicKeyBase64Url, // 65 bytes decoded
54
+ privateKey: privateKeyBase64Url // 32 bytes decoded
55
+ };
56
+ }
@@ -23,23 +23,26 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
23
23
  }
24
24
  return this;
25
25
  }
26
- destroy() { // Subclasses extend to remove UI.
26
+ destroy() { // Subclasses extend to remove UI. Clients may call this directly. (Compare delete.)
27
27
  // Subclass extensions of ensure may expect this answer falsy, indicating that an item was removed.
28
28
  // NOTE: Does not destroy replies, as these may have been published by others.
29
29
  this.container.removeItem(this.tag);
30
30
  return null;
31
31
  }
32
+ delete() { // Form of destroy called by ensure() with no payload.
33
+ return this.destroy(); // Hook for recognizing that an item is "un-ensured" rather than destroyed directly.
34
+ }
32
35
 
33
36
  static async ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
34
37
  const {tag, payload, ...rest} = data;
35
38
  let item = container.getItem(tag); // existing item
36
- if (!payload) return item?.destroy();
39
+ if (!payload) return item?.delete();
37
40
 
38
41
  // update or create item.
39
42
  if (item) item = await item.update(data);
40
43
  else item = await (new kind().initialize({...data, container}));
41
44
 
42
- if (!item) return container.removeItem(tag)?.destroy();
45
+ if (!item) return container.removeItem(tag)?.delete();
43
46
  return container.setItem(item.tag, item); // initialize() may come up with a better tag.
44
47
  }
45
48
  }