@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.
@@ -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,11 +227,21 @@ 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
235
  static subscriptionQueue = Promise.resolve(); // Serialize updates so they don't overlap each other.
201
236
  static async updateSubscriptions({newKeys, oldKeys, throttleMS = 20} = {}) { // Update current subscriptions.
202
237
  // A value of {} passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
203
238
  return this.subscriptionQueue = this.subscriptionQueue.then(async () => {
239
+ if (oldKeys && !Object.keys(oldKeys).length) { // This was a reset for a new node.
240
+ // Update replies subscriptions for alert that have already been opened.
241
+ for (const alert of this.items) {
242
+ if (alert.subscribed) await alert.ensureRepliesSubscribed(true);
243
+ }
244
+ }
204
245
  oldKeys ||= this.subscriptions;
205
246
  newKeys ||= this.subscriptionFromMap();
206
247
 
@@ -212,8 +253,13 @@ export class Alert extends Conversation { // A wrapper around L.marker
212
253
  const subscribe = async (eventName, handler) => {
213
254
  if (!eventName) console.log('sub to no eventName', {oldKeys, newKeys, dropped, added, handler});
214
255
  const region = topicRegion(eventName);
256
+ const pushData = handler && await this.pushData;
215
257
  if (handler) Agent.current?.trackPublicChanges(region); // Background. No need to await.
216
- await contact.subscribe({eventName, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
258
+ return await contact.subscribe({eventName, region, handler, pushData, pushPersist: null})
259
+ .then(sub => {
260
+ throttleMS && P2PWebNetwork.delay(throttleMS);
261
+ return sub;
262
+ });
217
263
  };
218
264
  for (const key in newKeys) oldKeys.hasOwnProperty(key) || added.push(key);
219
265
  for (const key in oldKeys) newKeys.hasOwnProperty(key) || dropped.push(key);
@@ -230,8 +276,29 @@ export class Alert extends Conversation { // A wrapper around L.marker
230
276
 
231
277
  for (const key of added) await subscribe(key, data => Alert.ensure(data));
232
278
  for (const key of dropped) await subscribe(key, null);
279
+ contact.pushPersist();
280
+ });
281
+ }
282
+ static clearPushData() { // Force new push subscription when next asked. Not needed at startup, but when creating a new node.
283
+ this._pushData = null; // Otherwise, new subscriptions with the old data will be rejected by the service as being for an unsubscribed client.
284
+ }
285
+ static get pushData() {
286
+ if (!notificationsAllowed()) return null;
287
+ if (this._pushData) return this._pushData;
288
+ return this._pushData = new Promise(async resolve => {
289
+ const {publicKey:applicationServerKey, privateKey:applicationId} = await generateVapidKeys();
290
+ const registration = await navigator.serviceWorker.ready;
291
+ const data = await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey});
292
+ const json = data.toJSON();
293
+ //console.log('pushData', {registration, data, json});
294
+ resolve({...json, applicationId, applicationServerKey});
233
295
  });
234
296
  }
297
+ static async refreshPushSubscriptions() { // Make sticky subscriptions match a new state.
298
+ const contact = await networkPromise;
299
+ await contact.resetPersisted();
300
+ await this.updateSubscriptions({newKeys: this.subscriptions, oldKeys: {}});
301
+ }
235
302
 
236
303
  // Instance Management Internals
237
304
  // In general here, a key is an eventName - i.e., a string <mumble>:<cellID>:<hashtag>.
@@ -254,15 +321,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
254
321
  this.eventName = eventName;
255
322
  if (!this.isAggregate) return;
256
323
  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});
324
+ this.cellBorder = this.constructor.displayCell(topicCell(eventName));
325
+ }
326
+ static displayCell(cell, color = this.md_sys_color_secondary) { // And return cell for caching/removal.
327
+ // Draw polygon border from cell corners.
328
+ const corners = getCellCorners(cell);
329
+ const border = L.polygon(corners, {color, fillOpacity: 0.1});
265
330
  border.addTo(map);
331
+ return border;
266
332
  }
267
333
  static md_sys_color_secondary = '#BD2E2F';
268
334
  static forEachAlertOf(eventName, callback, alerts = this.items) { // Apply callback to each of alerts matching eventName.
@@ -394,7 +460,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
394
460
 
395
461
  // We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
396
462
  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.
463
+ static shownCells = false; // Empty array for stashing debug polygons, else falsy.
464
+ static subscriptionFromMap() { // Generate new subscriptions list ({eventName => count}) for current map bounds,
465
+ // and additionally, a 1x1 degree box around the user's last known position, if any, regardless of map bounds.
398
466
  const center = map.getCenter();
399
467
  const bounds = map.getBounds();
400
468
  const northEast = bounds.getNorthEast();
@@ -408,6 +476,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
408
476
  maxLng: northEast.lng
409
477
  });
410
478
  if (!newCells) return null;
479
+ if (this.shownCells) { // debugging
480
+ this.shownCells.forEach(polygon => polygon.removeFrom(map));
481
+ const mapPolys = newCells.map(cell => this.displayCell(cell));
482
+ const geoPolys = lastCells.map(cell => this.displayCell(cell, 'blue'));
483
+ this.shownCells = [...mapPolys, ...geoPolys];
484
+ }
485
+ // Alert.ensure will not pub multiple markers/notifications for the same alert, but let us not double subscriptions.
486
+ if (lastCells) lastCells.forEach(cell => newCells.includes(cell) || newCells.push(cell));
411
487
  const newKeys = {};
412
488
  newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => { // Populate count with existing count (exctly carried over cell sizes), else 0.
413
489
  const eventName = alertTopic(cell, hash);
@@ -437,7 +513,6 @@ export class Alert extends Conversation { // A wrapper around L.marker
437
513
  payload = {lat, lng, originalPosting}, // If payload is null (cancels tag), lat & lng are still used to generate eventNames.
438
514
  cancel = undefined, // First unpublish the specified data, if any. Complicated default.
439
515
  issuedTime = Date.now(), tag,
440
- throttleMS = 0,
441
516
  ...rest
442
517
  }) {
443
518
  // We call all the publishing at once and return tag, without waiting for each to occur.
@@ -457,14 +532,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
457
532
 
458
533
  const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
459
534
  let oldCells = null, oldHash, oldTag = null; // Recorded for logging, below.
460
- let lastFillIn;
535
+ let lastFillIn = {lat, lng, hashtag, issuedTime};
461
536
  if (payload) {
462
- lastFillIn = {lat, lng, hashtag, issuedTime};
463
537
  this.lastPublished.push(lastFillIn); // Capture the added data.
464
538
  const periodStart = Date.now() - (this.maxPublish * 60e3); // maxPublish minutes ago.
465
539
  this.lastPublished = this.lastPublished.filter(past => past.issuedTime >= periodStart);
466
540
  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');
541
+ setTimeout(() => showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.`),
542
+ 3e3); // If immediate, it will get covered by instructions on the new alert.
468
543
  cancel = this.lastPublished.shift();
469
544
  }
470
545
  }
@@ -473,36 +548,15 @@ export class Alert extends Conversation { // A wrapper around L.marker
473
548
  oldCells = getContainingCells(lat, lng);
474
549
  oldHash = hashtag; oldTag = tag;
475
550
  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
- }
551
+ await Promise.all(oldCells.map(cell => contact.publish({eventName: alertTopic(cell, hashtag), region, killTag: tag, payload: null})));
482
552
  }
483
553
 
484
554
  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
- }
555
+ const pubs = await Promise.all(eventNames.map(eventName =>
556
+ contact.publish({eventName, region, killTag: tag, payload: tag ? null : payload, issuedTime, hashtag, ...rest})));
557
+ if (payload) tag = lastFillIn.tag = pubs[0];
558
+ else this.lastPublished = this.lastPublished.filter(past => past.tag !== tag);
559
+
506
560
  console.log('Published', {cells, n: cells.length, region, hashtag, tag, payload, oldCells, oldHash, oldTag});
507
561
  return tag;
508
562
  } finally {
@@ -519,7 +573,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
519
573
  map.closePopup();
520
574
  Hashtags.closeSelector();
521
575
  }
522
- static openPopup(alertTag) { // Open the marker specified by tag.
576
+ static async openPopup(alertTag) { // Open the marker specified by tag.
577
+ await networkPromise;
523
578
  const wrapper = this.getItem(alertTag);
524
579
  wrapper?.openPopup() || (openOnReceive = alertTag);
525
580
  }
@@ -561,10 +616,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
561
616
  }
562
617
 
563
618
  needsRedisplay = true;
564
- ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
619
+ async ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
620
+ resetInactivityTimer();
565
621
  if (!popup) return;
566
622
  if (!popup.isOpen()) return;
567
623
  this.logAlert();
624
+ if (!this.subscribed) await this.ensureRepliesSubscribed();
568
625
  if (!this.needsRedisplay) {
569
626
  this.initializeHandlers(popup);
570
627
  return;
@@ -575,18 +632,27 @@ export class Alert extends Conversation { // A wrapper around L.marker
575
632
  let content = this.formatAttribution({agent, issuedTime, originalPosting, hashtag});
576
633
  content += this.formatReplies();
577
634
  popup.setContent(content);
578
- delay(100).then(() => {
579
- this.marker.getPopup().update();
635
+ const onFirstNewPopup = () => {
636
+ const popup = this.marker.getPopup();
637
+ if (!popup) delay(50).then(onFirstNewPopup);
638
+ popup.update();
580
639
  this.initializeHandlers(popup);
581
640
  this.teach('firstConversation');
582
641
  if (Agent.isMine(this.agent)) this.teach('firstPublish');
583
- });
642
+ };
643
+ onFirstNewPopup();
584
644
  }
585
645
  teach(classname) {
586
646
  if (localStorage.getItem(classname)) return;
587
647
  localStorage.setItem(classname, '1');
588
648
  document.body.classList.toggle(classname, true);
649
+ document.querySelectorAll('.teach').forEach(element => element.onclick = () => this.closeTeach());
589
650
  }
651
+ closeTeach() { // All of them.
652
+ document.body.classList.toggle('firstConversation', false);
653
+ document.body.classList.toggle('firstPublish', false);
654
+ }
655
+
590
656
  clearAvatars(popup = this.marker?.getPopup()) {
591
657
  popup?.getElement()?.querySelectorAll('.correspondent[data-tag]')
592
658
  .forEach(element => Agent.ensure({tag: element.dataset.tag}).removeElement(element, 'mixed', element.classList.contains('avatar') ? 'avatar' : 'handle'));
@@ -614,10 +680,10 @@ export class Alert extends Conversation { // A wrapper around L.marker
614
680
  filenameDisplay.textContent = fileChooser.files.length ? (fileChooser.files[0].name || 'camera') : '';
615
681
  };
616
682
  this.initChangeHashtag(popupElement);
683
+ const region = this.region;
617
684
  for (const correspondent of popupElement.querySelectorAll('.correspondent')) {
618
685
  const tag = correspondent.dataset.tag;
619
- const region = P2PWebNetwork.regionCode(this.lat, this.lng);
620
- const agent = Agent.ensure({tag, region: region});
686
+ const agent = Agent.ensure({tag, region});
621
687
  const isAvatar = correspondent.classList.contains('avatar');
622
688
  if (agent.addElement(correspondent, 'mixed', isAvatar ? 'avatar' : 'handle')) {
623
689
  const isMine = Agent.isMine(tag);
@@ -674,7 +740,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
674
740
  `;
675
741
  return `<md-outlined-button class="changeHashtag">${pubtag}</md-outlined-button>`;
676
742
  }
677
- formatAttributionActions({agent, hashtag}) { // Anser div HTML containing: [deleter] sharer [hashtag]
743
+ formatAttributionActions({agent, hashtag}) { // Answer div HTML containing: [deleter] sharer [hashtag]
678
744
  // Where deletere appears if it our reply (no hashtag), and hashtag if present is a button if ours (and otherwise just text).
679
745
  const isOurs = agent === Agent.tag;
680
746
  const deleter = !hashtag && isOurs ? `<md-outlined-icon-button><md-icon class="material-icons">delete_forever</md-icon></md-outlined-icon-button>` : '';
@@ -742,9 +808,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
742
808
  event.stopPropagation();
743
809
  const button = event.target;
744
810
  const inputElement = button.parentElement;
745
- let payload = inputElement.value.trim();
746
- const {tag, hashtag, lat, lng} = this;
747
- const region = P2PWebNetwork.regionCode(lat, lng);
811
+ const {tag, hashtag, lat, lng, region} = this;
812
+ let payload = {message: inputElement.value.trim(), lat, lng};
748
813
  const files = inputElement.parentElement.querySelector('input[type="file"]').files;
749
814
  if (!payload && !files.length) return;
750
815
  inputElement.value = '';
@@ -752,15 +817,17 @@ export class Alert extends Conversation { // A wrapper around L.marker
752
817
  const contact = await networkPromise;
753
818
  if (files.length) {
754
819
  const {topic:file, msgIds} = await contact.chunkifyBlob({blob: files[0], region});
755
- payload = {message: payload, file};
820
+ payload.file = file;
756
821
  }
757
- await contact.publish({eventName: tag, region, payload}); // Publish the new reply.
822
+ // Users won't subscribe to tag unless the alert itself has already landed, and that has lat/lng/hashtag.
823
+ // However, these must be included anyway in case the receiving user clicks on an out-of-band
824
+ // notification at a later time, without the alert being on the map at that moment.
825
+ await contact.publish({eventName: tag, region, payload, hashtag, alert: tag}); // Publish the new reply.
758
826
  Agent.current.persistPublicMetadata();
759
827
  }
760
828
  deleteReply(replyElement) {
761
829
  resetInactivityTimer();
762
- const {lat, lng, tag} = this;
763
- const region = P2PWebNetwork.regionCode(lat, lng);
830
+ const {lat, lng, region, tag} = this;
764
831
  const killTag = replyElement.dataset.tag;
765
832
  networkPromise.then(async contact => {
766
833
  // We won't be here unless we are the signer.
@@ -776,30 +843,19 @@ export class Alert extends Conversation { // A wrapper around L.marker
776
843
  }
777
844
  });
778
845
  }
779
- showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
846
+ showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag, force = false}) {
780
847
  // Give OS notification that comes back to here, unless act is us.
781
848
  // All notifications on the same alert (e.g., the post and each reply) have the same tag, so OS can collapse them.
849
+ //console.log('alert showNotification', {lat, lng, hashtag, alert, agent, body, force});
782
850
  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
- });
851
+ postServiceMessage('notify', {lat, lng, issuedTime, hashtag, alert, body, force});
796
852
  }
797
853
  // Each reply element is a DIV.reply with data-tag and data-text attributes that are used in sharing.
798
854
  // It contains an attribution header with controls, zero or one attachments, and then the message text.
799
855
  // 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
856
  formatReplies() { // Answer HTML for the replies and input box.
801
857
  const { items, agent, originalPosting } = this;
802
- const formatReply = ({tag, payload, ...rest}) => {
858
+ const formatReply = ({tag, payload, hashtag, ...rest}) => {
803
859
  const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
804
860
  let text = message
805
861
  .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
@@ -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
  }
@@ -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();
@@ -125,7 +125,8 @@ export const Hashtags = {
125
125
  if (resetSubscriptions) {
126
126
  // 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
127
  // 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());
128
+ // The remaining have needsRedisplay set so that their menus are correct.
129
+ Alert.items.forEach(wrapper => (this.isSubscribed(wrapper.hashtag) && (wrapper.needsRedisplay = true)) || wrapper.destroy());
129
130
  Alert.updateSubscriptions();
130
131
  }
131
132
  },