@yz-social/civildefense.io 5.0.3 → 5.1.1

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yz-social/civildefense.io",
3
3
  "description": "Browser app to safely share live sightings on a map.",
4
- "version": "5.0.3",
4
+ "version": "5.1.1",
5
5
  "keywords": [
6
6
  "map",
7
7
  "browser",
@@ -110,7 +110,7 @@
110
110
  </ul>
111
111
 
112
112
  <p>
113
- <a href="/?tags=%F0%9F%8E%B8%20classic%20rock,%F0%9F%8E%BC%20classical,%F0%9F%A4%A0%20country,%F0%9F%8E%B7%20jazz,%F0%9F%8E%99%EF%B8%8F%20news,%F0%9F%8E%B5%20pop&z=5&lang=en" class="launch-btn" target="civildefense">
113
+ <a href="/?tags=%F0%9F%94%A5%20fire&z=5&lang=en" class="launch-btn" target="civildefense">
114
114
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
115
115
  Try it!
116
116
  </a>
@@ -168,7 +168,7 @@
168
168
  <h2 id="censorship">Resist Censorship</h2>
169
169
  <ul>
170
170
  <li>No user can be censored because there are no accounts. No one can steal your account information because there isn't any.</li>
171
- <li>No content can be censored, there is no central organization who controls the network or has power to alter content.<a href="#p2p"><sup>1</sup></a></li>
171
+ <li>No content can be censored because there is no central organization who controls the network or has power to alter content.<a href="#p2p"><sup>1</sup></a></li>
172
172
  </ul>
173
173
 
174
174
  <h2 id="takedown">Resist Takedown</h2>
@@ -72,10 +72,14 @@ a:hover { color: var(--red); }
72
72
  }
73
73
  .button-img { width: 80px; }
74
74
  a > img {
75
- box-shadow: 0 4px 28px rgba(255, 90, 0, 0.45);
75
+ box-shadow: 0 4px 24px rgba(0,0,0,0.4);
76
76
  border-radius: 18px;
77
77
  border: solid thick var(--red);
78
78
  }
79
+ a > img:hover {
80
+ transform: translateY(-2px);
81
+ transition: transform 0.15s;
82
+ }
79
83
  .hero-logo {
80
84
  width: 80px;
81
85
  height: 80px;
@@ -221,6 +225,8 @@ video + .carousel-caption {
221
225
  padding: 2.5rem 1.25rem 4rem;
222
226
  }
223
227
 
228
+ .content-wrap div.centered + p { padding-top: 2rem; }
229
+
224
230
  /* Section headings */
225
231
  .content-wrap h1 {
226
232
  font-family: var(--font-display);
@@ -288,9 +294,9 @@ video + .carousel-caption {
288
294
  letter-spacing: 0.08em;
289
295
  text-transform: uppercase;
290
296
  padding: 14px 38px;
291
- border-radius: 4px;
297
+ border-radius: 18px;
292
298
  text-decoration: none;
293
- box-shadow: 0 4px 28px rgba(255,90,0,0.45);
299
+ box-shadow: 0 8px 40px rgba(0,0,0,0.45);
294
300
  transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
295
301
  }
296
302
  .launch-btn:hover {
package/public/index.html CHANGED
@@ -11,13 +11,14 @@
11
11
  <meta name="twitter:card" content="summary_large_image">
12
12
  <meta name="twitter:image" content="https://civildefense.io/images/hero-small.png" />
13
13
 
14
- <link rel="author" href="https://yz.social/index.html" />
15
- <link rel="help" href="https://yz.social/civildefense.html" />
16
- <link rel="manifest" href="manifest.json" />
17
14
  <link rel="shortcut icon" href="favicon.ico" />
15
+ <link rel="manifest" href="manifest.json" />
16
+ <link rel="author" href="https://yz.social/index.html" />
17
+ <link rel="help" href="about/index.html" />
18
+ <link rel="apple-touch-icon" href="images/civil-defense-180-square.png" />
18
19
  <link rel="stylesheet" href="leaflet/leaflet.css" />
19
- <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
20
- <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
20
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" />
21
+ <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
21
22
  <link rel="stylesheet" href="stylesheets/style.css" />
22
23
  <script type="importmap">
23
24
  {
@@ -512,7 +512,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
512
512
  this.shownCells = [...mapPolys, ...geoPolys];
513
513
  }
514
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));
515
+ //fixme if (lastCells) lastCells.forEach(cell => newCells.includes(cell) || newCells.push(cell));
516
516
  const newKeys = {};
517
517
  newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => { // Populate count with existing count (exctly carried over cell sizes), else 0.
518
518
  const eventName = alertTopic(cell, hash);
@@ -142,6 +142,116 @@ export const Hashtags = {
142
142
  <md-icon-button slot="remove-trailing-icon"><md-icon class="material-icons"></md-icon></md-icon-button>
143
143
  </md-filter-chip>`;
144
144
  },
145
+ sort(tags) { // Sort list of tags in place without regard to leding emoji
146
+ tags.sort((a, b) => stripLeadingEmoji(a).localeCompare(stripLeadingEmoji(b)));
147
+ },
148
+ resetSubscriberDisplay() { // Lay out all the hashtag chips display, including the input for adding new ones.
149
+ this.chipset.innerHTML = '';
150
+ const tags = this.getAll();
151
+
152
+ // Sort alphabetically, ignoring any leading emoji, as these have unexpected orderings.
153
+ this.sort(tags);
154
+ const reordered = {};
155
+ tags.forEach(tag => reordered[tag] = this.hashtags[tag]);
156
+ this.hashtags = reordered;
157
+
158
+ // Add a chip for each hashtag.
159
+ tags.forEach(label => { // Elements are displayed from the bottom up.
160
+ this.chipset.insertAdjacentHTML("afterbegin", this.chipHTML(label));
161
+ });
162
+ // IWBNI we just added handlers once to the chipset and relied on bubbling up, but there's something not working about that.
163
+ [...this.chipset.children].forEach(element => {
164
+ // Material design will update the displays. We have to handle the data changes.
165
+ element.addEventListener('remove', event => { // Clicking the button WITHIN the chip (which is the material design 'remove' event).
166
+ resetInactivityTimer();
167
+ const chip = event.target;
168
+ if (!chip.selected) { // 'x' icon. Not currently selected. Go ahead and remove it.
169
+ showMessage(Int`Topic "${chip.label}" has been removed. You can add it back with "add topic".`, 'instructions');
170
+ return this.remove(chip);
171
+ } // radio button icon. Chip is already selected. We are setting the publishing tag.
172
+ event.preventDefault();
173
+ showMessage(Int`Tapping the map will now produce an alert for the "${chip.label}" topic.`, 'instructions');
174
+ if (chip.classList.contains('pub')) return false;
175
+ return this.setPublish(chip.label);
176
+ });
177
+ clickTip(element, Int`Toggle whether alerts for this topic shown on the map. Separately, a radio button is shown when selected and sets this as the initial topic of the next alert you make, while an x button is shown when deselected and removes the topic.`, event => { // Toggle action on whole chip.
178
+ event.stopPropagation();
179
+ resetInactivityTimer();
180
+ const chip = event.target;
181
+ if (chip.selected) showMessage(Int`Turning "${chip.label}" alerts back on in the map.`, 'instructions');
182
+ else showMessage(Int`Turning off "${chip.label}" alerts in the map. You can delete the topic altogether with the X.`, 'instructions');
183
+ this.toggleChip(chip);
184
+ Alert.closePopup();
185
+ this.onchange({redisplaySubscribers: false});
186
+ });
187
+ });
188
+ this.chipset.insertAdjacentHTML("afterbegin", // Chip to add a new hashtag.
189
+ `<div class="combobox">
190
+ <md-filled-text-field class="newtag"
191
+ aria-expanded="false"
192
+ aria-controls="knownTagsListbox"
193
+ aria-autocomplete="list"
194
+ autocomplete="off"
195
+ tabindex="0"
196
+ placeholder="➕${Int`add topic`}"></md-filled-text-field>
197
+ </div>`);
198
+ // I've tried also supplying a datalist, e.g., to supply the mobile keyboard completions, but
199
+ // I have not been able to get it to work.
200
+ this.initializeTopicInput();
201
+ },
202
+ remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
203
+ delete this.hashtags[chip.label];
204
+ delete this.canonical2extended[canonicalTag(chip.label)];
205
+ this.onchange({redisplaySubscribers, resetSubscriptions: false});
206
+ },
207
+ toggleChip(chip) { // Switch whether the topic is or is not subscribed.
208
+ // Now selected => hashtags[label] becomes 'pub' (selected and the publisher) and clear old pub
209
+ // NOT now selected => hashtags[label] becomes false (through mechanism as follows)
210
+ // but if publisher => set alt publisher if possible, else remember as backupPublisher
211
+ const label = chip.label;
212
+
213
+ // chip.selected is new state, after clicking.
214
+ if (chip.selected) return this.setPublish(label); // Become publisher, clearing old publisher.
215
+
216
+ // Not selected:
217
+
218
+ // If we're not publisher, just clear. But don't go through getPublish, as that can have side effects.
219
+ if (this.hashtags[label] !== 'pub') return this.hashtags[label] = false;
220
+
221
+ // Also clear, but...
222
+ const subs = this.getSubscribe();
223
+ if (subs.length > 1) { // Find and set alternative publisher if possible.
224
+ const pubIndex = subs.indexOf(label);
225
+ const index = (pubIndex + 1) % subs.length;
226
+ this.setPublish(subs[index]);
227
+ } else {
228
+ // No alternative available. Clear it, but remember for use by getPublish.
229
+ // It will stay .pub styled while toggled, until anything toggles on.
230
+ this.backupPublisher = label;
231
+ }
232
+ return this.hashtags[label] = false;
233
+ },
234
+ getChip(label) { // Handy for scripting, but not otherwise used in app.
235
+ for (const chip of this.chipset.children) {
236
+ if (chip.label === label) return chip;
237
+ }
238
+ return null;
239
+ },
240
+ setPublish(newTag) { // Make this topic be the one to be used when we next publish an alert.
241
+ // newTag will be marked for publishing (in this.hashtags and element style)
242
+ // Old publish tag (if any) will be set back to merely be subscribed (in same)
243
+ let oldTag = this.getPublish();
244
+ const backup = this.backupPublisher;
245
+ if (oldTag) this.hashtags[oldTag] = true; // true (instead of 'pub')
246
+ else if (backup) oldTag = backup;
247
+ this.backupPublisher = false;
248
+ this.hashtags[newTag] = 'pub';
249
+ for (const chip of this.chipset.children) {
250
+ if (chip.label === newTag) chip.classList.add('pub');
251
+ else if (chip.label === oldTag) chip.classList.remove('pub');
252
+ }
253
+ return oldTag;
254
+ },
145
255
 
146
256
  // Topic entry, with autocomplete.
147
257
  // - When you click the input box, it shows all the topics we know about.
@@ -157,6 +267,7 @@ export const Hashtags = {
157
267
  // - otherwise => exact contents of input box is used.
158
268
  closeSelector() { // Close the autocomplete tag selector
159
269
  const { listbox, newtag } = this;
270
+ for (const li of listbox.children) li.classList.toggle('active', false);
160
271
  listbox.classList.toggle('hidden', true);
161
272
  newtag.setAttribute('aria-expanded', 'false');
162
273
  newtag.removeAttribute('aria-activedescendant');
@@ -189,17 +300,20 @@ export const Hashtags = {
189
300
  this.acceptTag();
190
301
  },
191
302
  acceptTag() { // Add the new hashtag.
303
+ console.log('acceptTag', this.newtag?.value);
192
304
  resetInactivityTimer();
193
305
  let tag = this.newtag?.value // Get into standard form, but do not strip emoji or case into canonical yet.
194
306
  .replace(/^#/, '') // No leading hash
195
307
  .replace(':', '.') // Replace colon with some other separator.
196
308
  .replace(/\s+/g, ' ') // Replace multiple spaces with a single space
197
309
  .normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
310
+ console.log('tag:', tag);
198
311
  Alert.closePopup();
199
312
  if (!tag) return;
200
313
  if (this.firstEmoji(tag)) { // Possibly REPLACE existing with the new tag.
201
314
  const canonical = canonicalTag(tag);
202
315
  const existingExtended = this.canonical2extended[canonical];
316
+ console.log({canonical, existingExtended});
203
317
  if (existingExtended !== tag) {
204
318
  delete this.canonical2extended[canonical];
205
319
  delete this.hashtags[existingExtended];
@@ -207,6 +321,7 @@ export const Hashtags = {
207
321
  }
208
322
  tag = this.add(tag); // Might exist, in which case tag might now be extended.
209
323
  this.setPublish(tag);
324
+ console.log('onchange with', this.hashtags);
210
325
  this.onchange({highlightPublish: true});
211
326
  },
212
327
  activeIndex: -1,
@@ -241,14 +356,6 @@ export const Hashtags = {
241
356
  li.id = `tag-option-${i}`;
242
357
  li.setAttribute('role', 'option');
243
358
  li.innerHTML = this.formatPubtag(highlight(item, matchString), item);
244
- li.onclick = event => {
245
- // pointerdown would fire before the text field's blur event,
246
- // so we would not have to delay that. But then we would not
247
- // scroll properly by touch drag.
248
- event.preventDefault();
249
- event.stopPropagation();
250
- this.selectValue(item);
251
- };
252
359
  listbox.appendChild(li);
253
360
  });
254
361
  setTimeout(() => { // Needs a tick.
@@ -256,67 +363,45 @@ export const Hashtags = {
256
363
  const max = Math.max(125, width);
257
364
  newtag.style.width = max + 'px'; // Set newtag so that input box makes room for floating lis
258
365
  }, 50);
259
-
260
366
  this.openSelector();
261
367
  },
262
- sort(tags) { // Sort list of tags in place without regard to leding emoji
263
- tags.sort((a, b) => stripLeadingEmoji(a).localeCompare(stripLeadingEmoji(b)));
264
- },
265
- resetSubscriberDisplay() { // Lay out all the hashtag chips display, including the input for adding new ones.
266
- this.chipset.innerHTML = '';
267
- const tags = this.getAll();
268
-
269
- // Sort alphabetically, ignoring any leading emoji, as these have unexpected orderings.
270
- this.sort(tags);
271
- const reordered = {};
272
- tags.forEach(tag => reordered[tag] = this.hashtags[tag]);
273
- this.hashtags = reordered;
274
-
275
- // Add a chip for each hashtag.
276
- tags.forEach(label => { // Elements are displayed from the bottom up.
277
- this.chipset.insertAdjacentHTML("afterbegin", this.chipHTML(label));
278
- });
279
- // IWBNI we just added handlers once to the chipset and relied on bubbling up, but there's something not working about that.
280
- [...this.chipset.children].forEach(element => {
281
- // Material design will update the displays. We have to handle the data changes.
282
- element.addEventListener('remove', event => { // Clicking the button WITHIN the chip (which is the material design 'remove' event).
283
- resetInactivityTimer();
284
- const chip = event.target;
285
- if (!chip.selected) { // 'x' icon. Not currently selected. Go ahead and remove it.
286
- showMessage(Int`Topic "${chip.label}" has been removed. You can add it back with "add topic".`, 'instructions');
287
- return this.remove(chip);
288
- } // radio button icon. Chip is already selected. We are setting the publishing tag.
289
- event.preventDefault();
290
- showMessage(Int`Tapping the map will now produce an alert for the "${chip.label}" topic.`, 'instructions');
291
- if (chip.classList.contains('pub')) return false;
292
- return this.setPublish(chip.label);
293
- });
294
- clickTip(element, Int`Toggle whether alerts for this topic shown on the map. Separately, a radio button is shown when selected and sets this as the initial topic of the next alert you make, while an x button is shown when deselected and removes the topic.`, event => { // Toggle action on whole chip.
295
- event.stopPropagation();
296
- resetInactivityTimer();
297
- const chip = event.target;
298
- if (chip.selected) showMessage(Int`Turning "${chip.label}" alerts back on in the map.`, 'instructions');
299
- else showMessage(Int`Turning off "${chip.label}" alerts in the map. You can delete the topic altogether with the X.`, 'instructions');
300
- this.toggleChip(chip);
301
- Alert.closePopup();
302
- this.onchange({redisplaySubscribers: false});
303
- });
304
- });
305
- this.chipset.insertAdjacentHTML("afterbegin", // Chip to add a new hashtag.
306
- `<div class="combobox">
307
- <md-filled-text-field class="newtag"
308
- aria-expanded="false"
309
- aria-controls="knownTagsListbox"
310
- aria-autocomplete="list"
311
- autocomplete="off"
312
- tabindex="0"
313
- placeholder="➕${Int`add topic`}"></md-filled-text-field>
314
- </div>`);
315
- // I've tried also supplying a datalist, e.g., to supply the mobile keyboard completions, but
316
- // I have not been able to get it to work.
368
+ initializeTopicInput() {
369
+ // Getting this correct is hard.
370
+ // We want all of the following to be true, on ALL BROWSERS, INCLUDING MOBILE BROWSERS:
371
+ // 0. The UL listbox starts as display:none.
372
+ // 1. When the INPUT newtag is clicked, the UL and it's LI become visible.
373
+ // 2. When the combined height of the LI elements exceeds that of the UL, they must be scrollable.
374
+ // 3. When a UL > LI is visible and clicked, an action is taken in the app, and the UL becomes display:none again.
375
+ // 4. When the INPUT loses focus by any other action, the UL becomes display:none again without the app action.
376
+ // 5. When a UL > LI is visible and clicked, the map underneath does NOT receive a click.
317
377
  const newtag = this.newtag = this.chipset.querySelector('.newtag');
318
378
  const listbox = this.listbox = document.querySelector('.combobox-listbox');
379
+ // Specifically mousedown, not pointerdown.
380
+ // Stop the dropdown from stealing focus -> stops premature blur
381
+ // and is also what keeps the UL on top for the WHOLE mousedown->mouseup->click
382
+ // sequence, which is what keeps us from click through to map.
383
+ listbox.onmousedown = event => event.preventDefault();
384
+ listbox.onclick = event => {
385
+ const li = event.target.closest('li');
386
+ console.log('listbox click', li);
387
+ if (!li) return;
388
+ this.selectValue(li.textContent);
389
+ this.closeSelector(); // safe here — this click's path is already resolved
390
+ };
391
+ newtag.oninput = () => this.renderSelector(newtag.value);
392
+ newtag.onchange = () => {
393
+ if (this.activeIndex < 0) {
394
+ this.acceptTag(); // As is, not from list.
395
+ } else {
396
+ this.selectValue(this.selectors[this.activeIndex]);
397
+ }
398
+ },
399
+ newtag.onblur = () => {
400
+ console.log('input blur');
401
+ this.closeSelector();
402
+ },
319
403
  clickTip(newtag, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
404
+ console.log('input click');
320
405
  event.stopPropagation();
321
406
  Alert.closePopup();
322
407
  resetInactivityTimer();
@@ -324,7 +409,7 @@ export const Hashtags = {
324
409
  if (navigator.maxTouchPoints <= 1) { // Only when no multi-touch. On-screen keyboard makes it shoot off the top.
325
410
  showMessage(Int`Type a new topic name to see any alerts on the map with this topic.`, 'instructions');
326
411
  }
327
- });
412
+ }),
328
413
  newtag.onkeydown = event => {
329
414
  if (listbox.classList.contains('hidden')) return;
330
415
  const optionCount = listbox.querySelectorAll('.combobox-option').length;
@@ -332,11 +417,7 @@ export const Hashtags = {
332
417
  switch (event.key) {
333
418
  case 'Enter':
334
419
  event.preventDefault();
335
- if (this.activeIndex < 0) {
336
- this.acceptTag(); // As is, not from list.
337
- } else {
338
- this.selectValue(this.selectors[this.activeIndex]);
339
- }
420
+ newtag.onchange();
340
421
  break;
341
422
 
342
423
  case 'Escape':
@@ -363,63 +444,6 @@ export const Hashtags = {
363
444
  this.activeIndex = -1;
364
445
  }
365
446
  };
366
- newtag.oninput = () => this.renderSelector(newtag.value);
367
- // When we click on the listbox, the browser will first blur newtag, and then
368
- // we would not get the click! So here we delay closing a bit.
369
- newtag.onblur = () => setTimeout(() => this.closeSelector(), 200);
370
- },
371
- remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
372
- delete this.hashtags[chip.label];
373
- delete this.canonical2extended[canonicalTag(chip.label)];
374
- this.onchange({redisplaySubscribers, resetSubscriptions: false});
375
- },
376
- toggleChip(chip) { // Switch whether the topic is or is not subscribed.
377
- // Now selected => hashtags[label] becomes 'pub' (selected and the publisher) and clear old pub
378
- // NOT now selected => hashtags[label] becomes false (through mechanism as follows)
379
- // but if publisher => set alt publisher if possible, else remember as backupPublisher
380
- const label = chip.label;
381
-
382
- // chip.selected is new state, after clicking.
383
- if (chip.selected) return this.setPublish(label); // Become publisher, clearing old publisher.
384
-
385
- // Not selected:
386
-
387
- // If we're not publisher, just clear. But don't go through getPublish, as that can have side effects.
388
- if (this.hashtags[label] !== 'pub') return this.hashtags[label] = false;
389
-
390
- // Also clear, but...
391
- const subs = this.getSubscribe();
392
- if (subs.length > 1) { // Find and set alternative publisher if possible.
393
- const pubIndex = subs.indexOf(label);
394
- const index = (pubIndex + 1) % subs.length;
395
- this.setPublish(subs[index]);
396
- } else {
397
- // No alternative available. Clear it, but remember for use by getPublish.
398
- // It will stay .pub styled while toggled, until anything toggles on.
399
- this.backupPublisher = label;
400
- }
401
- return this.hashtags[label] = false;
402
- },
403
- getChip(label) { // Handy for scripting, but not otherwise used in app.
404
- for (const chip of this.chipset.children) {
405
- if (chip.label === label) return chip;
406
- }
407
- return null;
408
- },
409
- setPublish(newTag) { // Make this topic be the one to be used when we next publish an alert.
410
- // newTag will be marked for publishing (in this.hashtags and element style)
411
- // Old publish tag (if any) will be set back to merely be subscribed (in same)
412
- let oldTag = this.getPublish();
413
- const backup = this.backupPublisher;
414
- if (oldTag) this.hashtags[oldTag] = true; // true (instead of 'pub')
415
- else if (backup) oldTag = backup;
416
- this.backupPublisher = false;
417
- this.hashtags[newTag] = 'pub';
418
- for (const chip of this.chipset.children) {
419
- if (chip.label === newTag) chip.classList.add('pub');
420
- else if (chip.label === oldTag) chip.classList.remove('pub');
421
- }
422
- return oldTag;
423
447
  }
424
448
  };
425
449
  globalThis.Hashtags = Hashtags; // for debugging
@@ -16,24 +16,16 @@ window.P2PWebNetwork = P2PWebNetwork;
16
16
  document.getElementById('appVersion').textContent = appVersion;
17
17
  document.getElementById('kernelVersion').textContent = P2PWebNetwork.kernelVersion;
18
18
 
19
- const RETRY_SECONDS = 90;
20
- const INACTIVITY_SECONDS = 5 * 60; // five minutes
21
-
22
19
  export function delay(ms = 800, value = undefined) { // Promise resolves to value after specified milliseconds.
23
20
  return new Promise(resolve => setTimeout(resolve, ms, value));
24
21
  }
25
22
 
26
- var inactivityTimer = null, reconnectCountdown, networkPromise = null;
27
- export { networkPromise };
28
- export async function resetInactivityTimer(clearMessage = true) { // if !network, initialize(false), else disconnect after INACTIVITY_SECONDSif not restarted
23
+ export var networkPromise = null;
24
+ export async function resetInactivityTimer(clearMessage = true) { // if !networkPromise, initialize(false).
25
+ // Historically, this also managed a timer that disconnected from network after a perdiod with no activity.
29
26
  //console.log('resetInactivityTimer, networkPromise:', networkPromise);
30
27
  if (clearMessage) showMessage('');
31
- clearTimeout(inactivityTimer);
32
- clearInterval(reconnectCountdown);
33
28
  if (!networkPromise) return initialize(false);
34
- // return inactivityTimer = setTimeout(() => {
35
- // networkPromise?.then(contact => contact.disconnect());
36
- // }, INACTIVITY_SECONDS * 1e3);
37
29
  }
38
30
 
39
31
  function asElement(elementOrQuerySelector) { // Return an element - treating a string arg as a query selector, or just return the arg.
@@ -81,7 +73,7 @@ export function notificationsAllowed() { // Combined result.
81
73
  return postServiceMessage && (Notification?.permission === 'granted') && notificationsRequested();
82
74
  }
83
75
  let notificationsPreviouslyAllowed = notificationsAllowed();
84
- function noteNotificationPermission(permission) { // Update permission controls/text/persistence.
76
+ function noteNotificationPermission(permission = window.Notification?.permission) { // Update permission controls/text/persistence.
85
77
  // 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).
86
78
  if (isWebView()) {
87
79
  showNotifications.checked = false;
@@ -132,12 +124,9 @@ showNotifications.onchange = () => {
132
124
  localStorage.setItem('notificationsRequested', showNotifications.checked ? '1' : '');
133
125
  noteNotificationPermission('granted');
134
126
  } else {
135
- window.Notification?.requestPermission().then(noteNotificationPermission());
127
+ window.Notification?.requestPermission().then(noteNotificationPermission);
136
128
  }
137
129
  };
138
- // Safari never fires 'change': https://webkit.org/b/259432
139
- navigator.permissions.query({ name: 'notifications'})
140
- .then(status => status.onchange = () => noteNotificationPermission(window.Notification?.permission));
141
130
 
142
131
  export function closeAll() { // Close anything that might be open.
143
132
  closeAbout();
@@ -146,11 +135,17 @@ export function closeAll() { // Close anything that might be open.
146
135
  ['aboutContainer', 'updateContainer', 'correspondentContainer', 'qrContainer']
147
136
  .forEach(tag => document.getElementById(tag).classList.toggle('hidden', true));
148
137
  }
138
+ let notificationPermissionInterval;
149
139
  export function openAbout(event) {
150
140
  openDisplay('aboutContainer', event);
151
- noteNotificationPermission(window.Notification?.permission);
141
+ noteNotificationPermission();
142
+ // I'd rather do:
143
+ // navigator.permissions.query({ name: 'notifications'}).then(status => status.onchange = () => noteNotificationPermission());
144
+ // but Safari never fires 'change' - https://webkit.org/b/259432 - and it's simpler to the following for all rather than just for some.
145
+ notificationPermissionInterval = setInterval(noteNotificationPermission, 1e3);
152
146
  }
153
147
  export function closeAbout() {
148
+ clearInterval(notificationPermissionInterval);
154
149
  document.getElementById('aboutContainer').classList.toggle('hidden', true);
155
150
  }
156
151
  document.getElementById('scriptChooser').onchange = event => { // Run a script module chosen by the user. e.g., for testing.
@@ -200,28 +195,13 @@ clickTip('#share', Int`${osName()} share a link to this map and topics, with pic
200
195
 
201
196
  clickTip('#recenterButton', Int`Recenter the map to where you are in the world.`, recenterMap);
202
197
 
203
- function checkOnline() { //true if online and visible, else cancel reconnectCountdown and inactivityTimeout, and show "offline"
198
+ function checkOnline() { //true if online and visible, else cancel inactivityTimeout, and show "offline"
204
199
  //console.log('checkOnline', navigator.onLine && !document.hidden);
205
200
  if (navigator.onLine && !document.hidden) return true;
206
- clearTimeout(inactivityTimer);
207
- clearInterval(reconnectCountdown);
208
201
  if (!navigator.onLine) showMessage(Int`No network connection.`, 'error');
209
202
  else console.warn('hidden');
210
203
  return false;
211
204
  }
212
- function resetReconnectCountdown() { // if !checkOnline each second, show time remaining; at expiration initialize(false)
213
- console.log('resetReconnectCountdown');
214
- clearInterval(reconnectCountdown);
215
- let counter = RETRY_SECONDS;
216
- reconnectCountdown = setInterval(() => {
217
- if (!checkOnline()) return null;
218
- if (counter > 1) return showMessage(Int`Disconnected. Retrying in ` + counter-- + Int` seconds.`, 'error');
219
- showMessage('');
220
- console.log('countdown timer expired');
221
- clearInterval(reconnectCountdown);
222
- return initialize(false);
223
- }, 1e3);
224
- }
225
205
 
226
206
  export let positionWatch;
227
207
  let subscribeOneShot;
@@ -305,6 +285,7 @@ let checking = false; // For debouncing.
305
285
  // On startup, get last persisted portals list, else the portal we came in on.
306
286
  //fixme let portals = new Set(JSON.parse(localStorage.getItem('portals') || `["${new URL('/kdht', window.location).href}"]`));
307
287
  async function initialize(event) { // Ensure there is a network promise and map, and reset geolocation:
288
+ // Called with no event by startup and resetInactivityTimer, and with event by handlers for visibilitychange and online.
308
289
  // debounce
309
290
  // if !checkOnline(), return
310
291
  // set network to promise a new Contact, set ondisconnect, and connect.
@@ -315,6 +296,7 @@ async function initialize(event) { // Ensure there is a network promise and map,
315
296
  try {
316
297
  // Always close about display, because notification permissions and the like can change in the OS while we're hidden, and safari and mobile chrome don't issue change events for them.
317
298
  closeAbout();
299
+ if (event) await delay(); // In some cases (iOS PWA), we may get online or visibilitychange BEFORE the previous network close event. So give that a moment.
318
300
 
319
301
  // If networkPromise has not yet been set (or cleared by disconnect), we will be subscribing.
320
302
  const needsConnection = !networkPromise;
@@ -360,15 +342,6 @@ async function initialize(event) { // Ensure there is a network promise and map,
360
342
  // so as not to confuse other nodes that have given up on the unresponsive old GUID.
361
343
  showMessage(message, 'error');
362
344
  });
363
- //contact.connect(/*fixme ...portals*/)
364
- // .then(() => contact.subscribe({ // Add and persist any new portals we haven't heard about.
365
- // eventName: 'sys:portals',
366
- // handler: data => {
367
- // const operation = data.payload ? 'add' : 'delete';
368
- // const resultSet = portals[operation](data.subject);
369
- // localStorage.setItem('portals', JSON.stringify([...resultSet]));
370
- // }
371
- // }))
372
345
  });
373
346
  }
374
347
  await Agent.initialize();
@@ -79,7 +79,7 @@ export class P2PWebNetwork {
79
79
  const [pushId, ...topics] = JSON.parse(lastPushed);
80
80
  await Promise.all([
81
81
  navigator.serviceWorker.ready
82
- .then(registration => registration.pushManager.getSubscription())
82
+ .then(registration => registration.pushManager?.getSubscription())
83
83
  .then(subscription => subscription?.unsubscribe()),
84
84
  ...topics.map(topic => peer.unsub(topic, {pushId}))
85
85
  ]);
@@ -95,15 +95,18 @@ if (dht < 1) {
95
95
  socket.onmessage = event => {
96
96
  const [tag, ...rest] = JSON.parse(event.data);
97
97
  const subHandler = handlers[tag];
98
+ if (subHandler) {
99
+ const [ackTag, ...parameters] = rest;
100
+ console.log('event', ackTag);
101
+ socket.send(JSON.stringify([0, ackTag]));
102
+ return subHandler(...parameters);
103
+ }
98
104
  const inFlightResolver = inFlight[tag];
99
-
100
- // if ((!subHandler && !inFlightResolver) || // debug
101
- // ((typeof(subHandler) !== 'function') && (typeof(inFlightResolver) !== 'function')))
102
- // console.warn('no handler or request', {tag, rest, subHandler, inFlightResolver, handlers, inFlight});
103
-
104
- if (subHandler) return subHandler(...rest);
105
- delete inFlight[tag];
106
- return inFlightResolver?.(...rest);
105
+ if (inFlightResolver) {
106
+ delete inFlight[tag];
107
+ return inFlightResolver(...rest);
108
+ }
109
+ return console.log('unexpected message', event.data);
107
110
  };
108
111
  socket.onopen = () => {
109
112
  if (socket.readyState !== WebSocket.OPEN) return; // You would think that can't happen, but...
@@ -123,7 +126,7 @@ if (dht < 1) {
123
126
  disconnect = () => socket.close();
124
127
  } else {
125
128
  disconnect = () => null;
126
- operator.setReceiver((nodeTag, id, ...rest) => handlers[id](...rest));
129
+ operator.setSender((nodeTag, id, ...rest) => handlers[id](...rest));
127
130
  resolve((methodName, ...rest) => operator[methodName](...rest)); // send()
128
131
  }
129
132
  });
@@ -37,18 +37,26 @@ const PUBLISH_TIMEOUT = 24 * 60 * 60e3; // Delete after 24 hours.
37
37
  const TRACK_TIMEOUT = PUBLISH_TIMEOUT;
38
38
 
39
39
  let invoke;
40
- export function setReceiver(receiver) { // Set the means by which we fire events over a connection.
40
+ export function setSender(receiver) { // Set the means by which we fire events over a direct connection.
41
41
  invoke = receiver; // E.g. (nodeId, handlerId, envelope) => getSocket(nodeId).send(JSON.stringify([handlerId, envelope]));
42
42
  }
43
- async function fireEvent(...rest) { // Send envelope to a subscribed hander.
44
- try {
45
- invoke(...rest);
46
- } catch (error) { // Error sending, e.g., nodeTag is gone and we had not yet noticed.
43
+
44
+ function directFireEvent(...rest) { // Send envelope to a subscribed hander.
45
+ return invoke(...rest).catch(async error => { // Error sending, e.g., nodeTag is gone and we had not yet noticed.
46
+ console.log(new Date(), error.message || error);
47
47
  const [nodeTag, id, envelope] = rest;
48
48
  await deleteSubscriber(nodeTag);
49
- const subscription = await store.get('sub', await deriveTopicId(envelope.topic), id);
50
- if (subscription) push(envelope, subscription, PUBLISH_TIMEOUT);
51
- }
49
+ const topic = envelope.topic;
50
+ const topicId = await deriveTopicId(topic);
51
+ const subscription = await store.get('sub', topicId, id);
52
+ console.log('push error activation:', topic, topicId, id, subscription);
53
+ if (subscription) push(envelope, subscription, PUBLISH_TIMEOUT); // Do not wait for push.
54
+ });
55
+ }
56
+
57
+ let burstQueue = Promise.resolve();
58
+ function sendBurst(thunk) { // Serialize outbursts so that we and a client that subscribed to a bunch both have time to send/accept ack's.
59
+ burstQueue = burstQueue.then(thunk);
52
60
  }
53
61
 
54
62
  export async function subscribe(topic, nodeTag, {since = 'all', pushData = null}) {
@@ -58,27 +66,34 @@ export async function subscribe(topic, nodeTag, {since = 'all', pushData = null}
58
66
  const id = uuidv4();
59
67
  await store.set('sub', topicId, nodeTag, id, SUBSCRIPTION_TIMEOUT);
60
68
  // If pushData, store it separately by nodeTag until it needs to be activated.
61
- if (pushData) await store.set('track', topicId, nodeTag, pushData, TRACK_TIMEOUT);
62
- else store.remove('track', topicId, nodeTag);
69
+ if (pushData) {
70
+ console.log('track push sub', topicId, nodeTag, pushData.endpoint.slice(0, 80));
71
+ await store.set('track', topicId, nodeTag, pushData, TRACK_TIMEOUT);
72
+ } else {
73
+ store.remove('track', topicId, nodeTag);
74
+ }
63
75
  if (since) { // invoke handler on any sticky data, but only after we have told client the subscription id. TODO: is there a better way?
64
76
  setTimeout(async () => {
65
- let lastEnvelope = null, lastTime = 0;
66
- for (const envelope of await store.values('pub', topicId)) {
67
- switch (since) {
68
- case 'all':
69
- await fireEvent(nodeTag, id, envelope);
70
- break;
71
- case 'latest':
72
- if (envelope.ts > lastTime) {
73
- lastTime = envelope.ts;
74
- lastEnvelope = envelope;
77
+ //sendBurst(async () => {
78
+ let lastEnvelope = null, lastTime = 0;
79
+ for (const envelope of await store.values('pub', topicId)) {
80
+ if (!await store.get('sub', topicId, nodeTag)) return;
81
+ switch (since) {
82
+ case 'all':
83
+ await directFireEvent(nodeTag, id, envelope);
84
+ break;
85
+ case 'latest':
86
+ if (envelope.ts > lastTime) {
87
+ lastTime = envelope.ts;
88
+ lastEnvelope = envelope;
89
+ }
90
+ break;
91
+ default: // Must be a timestamp
92
+ if (envelope.ts === since) await directFireEvent(nodeTag, id, envelope);
75
93
  }
76
- break;
77
- default: // Must be a timestamp
78
- if (envelope.ts === since) await fireEvent(nodeTag, id, envelope);
79
94
  }
80
- }
81
- if (lastEnvelope) fireEvent(nodeTag, id, lastEnvelope);
95
+ if (lastEnvelope) directFireEvent(nodeTag, id, lastEnvelope);
96
+ //});
82
97
  }, 100);
83
98
  }
84
99
  return {topicName: {name, region, owner, write}, topicId, id};
@@ -89,26 +104,28 @@ export async function unsubscribe(topic, nodeTag, {pushId}) {
89
104
  // Additionally, an activated sticky pushId will be removed if specified - it is the nodeTag from a previous session).
90
105
 
91
106
  const topicId = await deriveTopicId(topic);
92
- let id = await store.remove('sub', topicId, nodeTag);
107
+ let id = await store.remove('sub', topicId, nodeTag), tracked;
93
108
  // Also remove any sticky push subscription:
94
109
  // If not yet promoted to sub, it's in track under our current, protectable nodeTag.
95
110
  await store.remove('track', topicId, nodeTag);
96
111
  // Otherwise, it might have been activated in sub under pushId (i.e., a previous session nodeTag).
97
112
  // TODO: pushId should probably be a JWS that was signed in the subscribing node's previous session by the pushId,
98
113
  // thus proving that the request actually came from the succcessor to that node.
99
- if (pushId) pushId = await store.remove('sub', topicId, pushId); // pushId must match that used during activation.
100
- return {ok: !!id, id, pushId}; // Axona doesn't return the id(s) of the subscription(s), but it is convenient for us to do so.
114
+ if (pushId) tracked = await store.remove('sub', topicId, pushId); // pushId must match that used during activation.
115
+ //if (pushId) console.log('normal unsubscribe remove tracking', pushId, tracked?.endpoint.slice(0, 80));
116
+ return {ok: !!id, id, pushId: tracked}; // Axona doesn't return the id(s) of the subscription(s), but it is convenient for us to do so.
101
117
  }
102
118
 
103
119
  export async function deleteSubscriber(nodeTag) {
104
- // The node is gone: lost connection, or fireEvent failed.
105
-
120
+ // The node is gone: lost connection, or directFireEvent failed.
121
+ console.log('deleteSubscriber', nodeTag);
106
122
  for (const topicId of await store.topics('sub')) { // Remove in all topics.
107
- await store.remove('sub', topicId, nodeTag);
123
+ const sub = await store.remove('sub', topicId, nodeTag);
124
+ if (sub) console.log('removed sub', topicId, nodeTag, sub);
108
125
  // Activate pending push subscription, if any, by moving it from 'track' to active 'sub'.
109
126
  const pushSubscription = await store.remove('track', topicId, nodeTag);
110
127
  if (pushSubscription) {
111
- //console.log('activiting', topicId, nodeTag, pushSubscription);
128
+ console.log('activating', topicId, nodeTag, pushSubscription.endpoint.slice(0, 80));
112
129
  await store.set('sub', topicId, nodeTag, pushSubscription, TRACK_TIMEOUT);
113
130
  }
114
131
  }
@@ -122,9 +139,11 @@ async function handleEvents(topicId, envelope) {
122
139
  // Each sub's handlerInfo can be an ordinary subscription nodeId, or a sticky push subscription data.
123
140
  return Promise.all(subs.map(([nodeTag, handlerInfo]) => {
124
141
  if (isConnectedSubscription(handlerInfo)) { // nodeId
125
- return fireEvent(nodeTag, handlerInfo, envelope);
142
+ return directFireEvent(nodeTag, handlerInfo, envelope);
126
143
  } else { // activated sticky push subscription data object.
127
- return push(envelope, handlerInfo, PUBLISH_TIMEOUT);
144
+ console.log('pushing for nodeId', nodeTag, handlerInfo?.endpoint.slice(0, 80));
145
+ push(envelope, handlerInfo, PUBLISH_TIMEOUT);
146
+ return null; // Do not wait for push
128
147
  }
129
148
  }));
130
149
  }
@@ -153,3 +172,5 @@ export async function unpublish(topic, msgId, {signWith}) {
153
172
  await handleEvents(topicId, envelope);
154
173
  return {ok: true};
155
174
  }
175
+
176
+ export const operators = {setSender, subscribe, unsubscribe, deleteSubscriber, publish, unpublish};
@@ -23,6 +23,6 @@ export async function push(envelope, {applicationServerKey, applicationId, ...su
23
23
  // to correlate known topic activity with someone's push records obtained from the service.
24
24
  const reply = await webpush.sendNotification?.(subscription, JSON.stringify(envelope), options)
25
25
  .catch(error => console.error(`${error.message} for push ${subscription?.endpoint}.`));
26
- //console.log('push', {envelope, subscription, options, reply});
26
+ console.log('push', {msgId: envelope.msgId, endpoint: subscription.endpoint, status: reply?.statusCode});
27
27
  return reply;
28
28
  }
@@ -1,6 +1,6 @@
1
1
  const { Request, Response, URL, localStorage, BroadcastChannel } = globalThis;
2
2
  import { appVersion } from './versions.js';
3
- import { resetInactivityTimer, clickTip, closeAbout } from './main.js';
3
+ import { resetInactivityTimer, clickTip, closeAbout, delay } from './main.js';
4
4
  import { openDisplay } from './display.js';
5
5
  import { go, getShareableURL } from './alert.js';
6
6
  import { Int } from './translations.js';
@@ -79,7 +79,10 @@ async function installUpdate(event, newVersion) {
79
79
  event.stopPropagation();
80
80
  event.target.textContent = Int`Installing...`; // In case there is some delay, tell the user what we're trying to do. Will be cleared with reload.
81
81
  event.target.disabled = true;
82
- await caches.delete(appVersion); // Must be before cacheSource, or we'll just recache the same files!
82
+ // Must be before cacheSource, or we'll just recache the same files!
83
+ const cacheNames = await caches.keys();
84
+ await Promise.all(cacheNames.map(name => caches.delete(name))); // Includes ALL cache storage, such as seen notifications.
85
+
83
86
  await cacheSource(newVersion);
84
87
  closeAbout();
85
88
  // Reload, but convince all browsers to re-"fetch" (through the new service worker that is now running).
@@ -141,6 +144,8 @@ await navigator.serviceWorker
141
144
  resolveCached?.(params);
142
145
  break;
143
146
  case 'go':
147
+ await delay();
148
+ resetInactivityTimer();
144
149
  go(params);
145
150
  break;
146
151
  default:
@@ -1,6 +1,6 @@
1
1
  const { Request, Response, URL, clients} = self;
2
2
  // Little point in trying to automatically pull this through package.json, as we need a byte change in THIS file to trigger a new worker.
3
- const serviceVersion = '5.0.3';
3
+ const serviceVersion = '5.1.1';
4
4
 
5
5
  const cacheList = [ // The files we need.
6
6
  "/",
@@ -174,7 +174,7 @@ self.addEventListener('activate', async event => {
174
174
  // Apply to running clients now, so that first fresh install sees updatefound event.
175
175
  // Otherwise, the service worker wouldn't fire until the code NEXT time the page loads after
176
176
  // registration, and thus the initial load would not see any updatefound events.
177
- event.waitUntil(self.clients.claim());
177
+ event.waitUntil(self.clients.claim().then(expireStaleAlerts));
178
178
  });
179
179
 
180
180
  self.addEventListener('fetch', event => {
@@ -203,8 +203,36 @@ async function cacheSource(version, event) { // Cache source in the given versio
203
203
  return version;
204
204
  }
205
205
 
206
- const issued = new Set();
207
- function showNotification({lat, lng, issuedTime, hashtag, alert, body = '', force = false}) { // Promise to show a platform notification. There are two paths to here:
206
+ // The service worker is stopped and restarted for events, and so we can't keep an in-memory set of issued alerts.
207
+ // Here we keep it in cache, and manually clean up expired entries.
208
+ const alertCache = 'alerts-' + serviceVersion;
209
+ function hasSeenAlert(seenKey) { // Have we noted seenKey before?
210
+ return caches.open(alertCache).then(cache => cache.match(seenKey));
211
+ }
212
+ function noteSeenAlert(seenKey, issuedTime) { // Record seenKey and it's expiration timestamp.
213
+ return caches.open(alertCache).then(cache => cache.put(seenKey, new Response(issuedTime.toString())));
214
+ }
215
+ const ALERT_EXPIRATION_MS = 24 * 60 * 60e3;
216
+ let lastMaintenance = 0;
217
+ const SERVICE_INTERVAL = 60 * 60e3;
218
+ async function expireStaleAlerts() { // Maintenance: declutter notifications and cache storage.
219
+ const now = Date.now();
220
+ if (now < lastMaintenance + SERVICE_INTERVAL) return;
221
+ lastMaintenance = now;
222
+ const cache = await caches.open(alertCache);
223
+ const firstSafeIssuedTime = Date.now() - ALERT_EXPIRATION_MS;
224
+ const firstSafeIssuedString = firstSafeIssuedTime.toString();
225
+ for (const key of await cache.keys()) {
226
+ const response = await cache.match(key);
227
+ if (!response) console.error('how can there not be a response at', key);
228
+ else if (firstSafeIssuedString > await response.text()) {console.log('removing stale seen', key); cache.delete(key); }
229
+ }
230
+ for (const notification of await self.registration.getNotifications()) {
231
+ if (firstSafeIssuedTime > notification.data.issuedTime) {console.log('removing stale alert', notification.close());}
232
+ }
233
+ }
234
+
235
+ async function showNotification({lat, lng, issuedTime, hashtag, alert, body = '', force = false}) { // Promise to show a platform notification. There are two paths to here:
208
236
  // 1. The app handles data from network connections, and determines that it should alert the user.
209
237
  // In this case, the app sends the data to this service worker.
210
238
  // 2. An upstream node would like to send the data over the network, but finds that we are not connected,
@@ -213,21 +241,24 @@ function showNotification({lat, lng, issuedTime, hashtag, alert, body = '', forc
213
241
  // in path 2, so it has to be here if we want to have the code in just one place.
214
242
  // Any filtering (e.g., do not show notifications for one's own alerts) happens upstream of here. (We don't know the Agent.current and the sender isn't in the notification.)
215
243
  const seenKey = alert + body; // If we click on a notification for an off-screen alert, we may process the alert again and try to notify again.
216
- if (!force && issued.has(seenKey)) return null;
217
- issued.add(seenKey);
244
+ const seen = !force && await hasSeenAlert(seenKey);
245
+ console.log({seenKey, seen});
246
+ if (seen) return null;
247
+ noteSeenAlert(seenKey, issuedTime);
218
248
  const base = location.href;
219
249
  const timestamp = issuedTime;
220
250
  const icon = new URL('./images/civil-defense-192.png', base).href;
221
251
  const queryString = `./?tags=${encodeURIComponent(hashtag)}&lat=${lat}&lng=${lng}&alert=${alert}`;
222
252
  const url = new URL(queryString, base).href; // For opening page when it has been closed.
223
- const data = {lat, lng, url};
253
+ const data = {lat, lng, url, issuedTime};
224
254
  const options = {icon, timestamp, body, data, tag: alert, renotify: true};
225
255
  console.log('showNotification', {seenKey, hashtag, options});
226
- return self.registration.showNotification(hashtag, options);
256
+ await self.registration.showNotification(hashtag, options);
257
+ return expireStaleAlerts();
227
258
  }
228
259
 
229
260
  function showNotificationFromEnvelope({deleted, message, msgId}) { // We get the generic, application-independent envelope.
230
- //console.log('envelope', message, msgId);
261
+ console.log('received push', msgId, deleted ? 'cancel' : '');
231
262
  if (deleted) return cancelNotification(msgId);
232
263
  const {issuedTime, hashtag, payload, alert = msgId} = message;
233
264
  let {lat, lng, message:body, name = ''} = payload;
@@ -237,7 +268,7 @@ function showNotificationFromEnvelope({deleted, message, msgId}) { // We get the
237
268
 
238
269
  async function cancelNotification(tag, body = '') { // Cancel those that match tag, and if body, then only those also matching body.
239
270
  for (const notification of await self.registration.getNotifications({tag})) {
240
- console.log('cancel', {tag, body, notification});
271
+ console.log('cancel notification', {tag, body, notification});
241
272
  if (!body || (body === notification.body)) notification.close();
242
273
  }
243
274
  }
@@ -205,6 +205,7 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
205
205
  bottom: 55px;
206
206
  max-height: 45dvh; /* leaving room for portrait keyboard. landscape keyboard has no room at all *\/ */
207
207
  overflow-y: auto;
208
+ overscroll-behavior: contain; /* don't chain scroll to page */
208
209
  width: fit-content;
209
210
  min-width: var(--combobox-minWidth);
210
211
  z-index: 1000;
@@ -1,12 +1,47 @@
1
1
  // Offer a websocket connection for testing, that skips the DHT entirely, and accepts publishing by pushing to subscribers over the websocket.
2
2
  import { WebSocketServer } from 'ws';
3
- import * as operator from '../public/javascripts/pubsub.js';
3
+ import { operators } from '../public/javascripts/pubsub.js';
4
4
 
5
5
  const sockets = {};
6
- operator.setReceiver((nodeId, id, envelope) => {
6
+ operators.setSender((nodeId, eventHandlerId, envelope) => new Promise((resolve, reject) => {
7
+ // Setup pubsub to receive events, by supplying a function that promises to send the event.
8
+ //
9
+ // When a client goes to the background, it will stay connected for at least a few seconds. An alert during that time will be transmitted in the usual way.
10
+ // If the client politely closes, our close handler will catch this, and call pubsub's deleteSubscriber(), which activates any sticky subs.
11
+ // An alert after that be transmitted by the sticky push sub, if any.
12
+ // We have a 20 second heartbeat to all clients: when someone has gone dark by being in the background, that will supply the cose.
13
+ // An alert after heartbeat expiration wil be transmitted by the sticky push sub, if any.
14
+ // But... Sending on a websocket that isn't listening will silently appear to succeed.
15
+ // (The protocol is designed that way so that operating systems can buffer the output, so any tcp ack failure could occur
16
+ // long after socket.send() returned.)
17
+ // Oy.
18
+ // If the OS suspends the client, say, 5 seconds after going into the background, an alert between 5 and 20 seconds would not be delivered!
19
+ // In order to prevent this, outgoing events send an ackTag string (in addition to the event envelope), which the client must send back.
20
+ // Here we set the event publisher to answer a Promise. If the sender acknowledges the event within ACK_TIME_MS, it resolves to undefined.
21
+ // But if there is no acknowledgement (or if the updated readyState after sending is not OPEN), then the promise is rejected,
22
+ // and pubsub can catch that and transmit by the sticky push sub, if any.
7
23
  const socket = sockets[nodeId];
8
- socket.send(JSON.stringify([id, envelope])); // We want an error if socket is gone, closed, etc.
9
- });
24
+ if (!socket) return;
25
+ const ACK_TIME_MS = 20e3;
26
+ const ackTag = nodeId + '-' + socket.sendCounter++;
27
+ const fail = reason => {
28
+ delete sockets[nodeId]; // Keep close() from removing any sticky sub.
29
+ delete operators[ackTag];
30
+ socket.terminate(); // close will be asynchronous.
31
+ reject(reason); // Reject now (before close), so that caller can catch this and push message to sticky sub, if any.
32
+ };
33
+ console.log(new Date(), 'socket send', socket.readyState, ackTag, eventHandlerId);
34
+ const timer = setTimeout(() => fail(`No acknowledgement for ${ackTag} in state ${socket.readyState} for handler ${eventHandlerId}.`), ACK_TIME_MS);
35
+ operators[ackTag] = () => {
36
+ console.log('ack', ackTag, 'from eventHandlerId', eventHandlerId);
37
+ clearTimeout(timer);
38
+ delete operators[ackTag];
39
+ resolve();
40
+ };
41
+ socket.send(JSON.stringify([eventHandlerId, ackTag, envelope])); // We want an error if socket is gone, closed, etc.
42
+ // State might not update until we attempt to actually send.
43
+ if (socket.readyState !== WebSocket.OPEN) fail(`Bad state send to ${ackTag}, state ${socket.readyState} for handler ${eventHandlerId}.`);
44
+ }));
10
45
 
11
46
  function heartbeat() {
12
47
  this.isAlive = true;
@@ -17,13 +52,15 @@ export function configureWebsocket(server) {
17
52
  wss.on('connection', (ws, req) => {
18
53
  const nodeTag = req.url.slice(1);
19
54
  console.log('Connected', nodeTag);
55
+ ws.sendCounter = 0;
20
56
  sockets[nodeTag] = ws;
21
57
 
22
58
  ws.on('message', async message => {
23
59
  try {
24
- const [id, methodName, ...rest] = JSON.parse(message);
25
- const result = await operator[methodName](...rest);
26
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify([id, result]));
60
+ const parsed = JSON.parse(message);
61
+ const [id, methodName, ...rest] = parsed;
62
+ const result = await operators[methodName](...rest);
63
+ if (id && (ws.readyState === WebSocket.OPEN)) ws.send(JSON.stringify([id, result]));
27
64
  } catch (error) {
28
65
  console.error(`Incomming ${error.message}: ${message}\n${error.stack}`);
29
66
  }
@@ -34,9 +71,10 @@ export function configureWebsocket(server) {
34
71
 
35
72
  ws.on('error', console.error);
36
73
  ws.on('close', () => {
37
- console.log('Disconnected', nodeTag);
74
+ console.log(new Date(), 'Disconnected', nodeTag);
75
+ // Guarded, so that we don't double delete, and remove an active sticky sub.
76
+ if (sockets[nodeTag]) operators.deleteSubscriber(nodeTag);
38
77
  delete sockets[nodeTag];
39
- operator.deleteSubscriber(nodeTag);
40
78
  });
41
79
  });
42
80