@yz-social/civildefense.io 5.0.0 → 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 +1 -1
- package/public/about/add-topic-autocomplete.png +0 -0
- package/public/about/add-topic.png +0 -0
- package/public/about/can-happen.jpg +0 -0
- package/public/about/en.html +42 -33
- package/public/about/identity.html +17 -2
- package/public/about/join.webp +0 -0
- package/public/about/notification.html +40 -2
- package/public/about/posters.jpeg +0 -0
- package/public/about/pwa.html +9 -5
- package/public/about/style.css +18 -5
- package/public/about/train-radio-button.png +0 -0
- package/public/about/train.png +0 -0
- package/public/about/ukraine-fires.jpeg +0 -0
- package/public/about/ukraine-fires.png +0 -0
- package/public/images/civil-defense copy.png +0 -0
- package/public/images/civil-defense-180-square.png +0 -0
- package/public/index.html +6 -5
- package/public/javascripts/alert.js +84 -56
- package/public/javascripts/hashtags.js +152 -127
- package/public/javascripts/main.js +24 -47
- package/public/javascripts/map.js +8 -3
- package/public/javascripts/p2pWebNetwork.js +1 -1
- package/public/javascripts/protocol.js +12 -9
- package/public/javascripts/pubsub.js +55 -34
- package/public/javascripts/push.js +1 -1
- package/public/javascripts/service-manager.js +7 -2
- package/public/service-worker.js +41 -10
- package/public/stylesheets/style.css +41 -16
- package/server/websocket.js +51 -10
|
@@ -23,6 +23,7 @@ let allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
|
|
|
23
23
|
"🎙️ news",
|
|
24
24
|
"👁️ observer corps",
|
|
25
25
|
"🎵 pop",
|
|
26
|
+
"🚂 train",
|
|
26
27
|
"🧰 utility repairs"
|
|
27
28
|
]`);
|
|
28
29
|
|
|
@@ -141,6 +142,116 @@ export const Hashtags = {
|
|
|
141
142
|
<md-icon-button slot="remove-trailing-icon"><md-icon class="material-icons"></md-icon></md-icon-button>
|
|
142
143
|
</md-filter-chip>`;
|
|
143
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
|
+
},
|
|
144
255
|
|
|
145
256
|
// Topic entry, with autocomplete.
|
|
146
257
|
// - When you click the input box, it shows all the topics we know about.
|
|
@@ -156,6 +267,7 @@ export const Hashtags = {
|
|
|
156
267
|
// - otherwise => exact contents of input box is used.
|
|
157
268
|
closeSelector() { // Close the autocomplete tag selector
|
|
158
269
|
const { listbox, newtag } = this;
|
|
270
|
+
for (const li of listbox.children) li.classList.toggle('active', false);
|
|
159
271
|
listbox.classList.toggle('hidden', true);
|
|
160
272
|
newtag.setAttribute('aria-expanded', 'false');
|
|
161
273
|
newtag.removeAttribute('aria-activedescendant');
|
|
@@ -188,17 +300,20 @@ export const Hashtags = {
|
|
|
188
300
|
this.acceptTag();
|
|
189
301
|
},
|
|
190
302
|
acceptTag() { // Add the new hashtag.
|
|
303
|
+
console.log('acceptTag', this.newtag?.value);
|
|
191
304
|
resetInactivityTimer();
|
|
192
305
|
let tag = this.newtag?.value // Get into standard form, but do not strip emoji or case into canonical yet.
|
|
193
306
|
.replace(/^#/, '') // No leading hash
|
|
194
307
|
.replace(':', '.') // Replace colon with some other separator.
|
|
195
308
|
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
|
|
196
309
|
.normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
|
|
310
|
+
console.log('tag:', tag);
|
|
197
311
|
Alert.closePopup();
|
|
198
312
|
if (!tag) return;
|
|
199
313
|
if (this.firstEmoji(tag)) { // Possibly REPLACE existing with the new tag.
|
|
200
314
|
const canonical = canonicalTag(tag);
|
|
201
315
|
const existingExtended = this.canonical2extended[canonical];
|
|
316
|
+
console.log({canonical, existingExtended});
|
|
202
317
|
if (existingExtended !== tag) {
|
|
203
318
|
delete this.canonical2extended[canonical];
|
|
204
319
|
delete this.hashtags[existingExtended];
|
|
@@ -206,6 +321,7 @@ export const Hashtags = {
|
|
|
206
321
|
}
|
|
207
322
|
tag = this.add(tag); // Might exist, in which case tag might now be extended.
|
|
208
323
|
this.setPublish(tag);
|
|
324
|
+
console.log('onchange with', this.hashtags);
|
|
209
325
|
this.onchange({highlightPublish: true});
|
|
210
326
|
},
|
|
211
327
|
activeIndex: -1,
|
|
@@ -240,14 +356,6 @@ export const Hashtags = {
|
|
|
240
356
|
li.id = `tag-option-${i}`;
|
|
241
357
|
li.setAttribute('role', 'option');
|
|
242
358
|
li.innerHTML = this.formatPubtag(highlight(item, matchString), item);
|
|
243
|
-
li.onclick = event => {
|
|
244
|
-
// pointerdown would fire before the text field's blur event,
|
|
245
|
-
// so we would not have to delay that. But then we would not
|
|
246
|
-
// scroll properly by touch drag.
|
|
247
|
-
event.preventDefault();
|
|
248
|
-
event.stopPropagation();
|
|
249
|
-
this.selectValue(item);
|
|
250
|
-
};
|
|
251
359
|
listbox.appendChild(li);
|
|
252
360
|
});
|
|
253
361
|
setTimeout(() => { // Needs a tick.
|
|
@@ -255,67 +363,45 @@ export const Hashtags = {
|
|
|
255
363
|
const max = Math.max(125, width);
|
|
256
364
|
newtag.style.width = max + 'px'; // Set newtag so that input box makes room for floating lis
|
|
257
365
|
}, 50);
|
|
258
|
-
|
|
259
366
|
this.openSelector();
|
|
260
367
|
},
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
const reordered = {};
|
|
271
|
-
tags.forEach(tag => reordered[tag] = this.hashtags[tag]);
|
|
272
|
-
this.hashtags = reordered;
|
|
273
|
-
|
|
274
|
-
// Add a chip for each hashtag.
|
|
275
|
-
tags.forEach(label => { // Elements are displayed from the bottom up.
|
|
276
|
-
this.chipset.insertAdjacentHTML("afterbegin", this.chipHTML(label));
|
|
277
|
-
});
|
|
278
|
-
// IWBNI we just added handlers once to the chipset and relied on bubbling up, but there's something not working about that.
|
|
279
|
-
[...this.chipset.children].forEach(element => {
|
|
280
|
-
// Material design will update the displays. We have to handle the data changes.
|
|
281
|
-
element.addEventListener('remove', event => { // Clicking the button WITHIN the chip (which is the material design 'remove' event).
|
|
282
|
-
resetInactivityTimer();
|
|
283
|
-
const chip = event.target;
|
|
284
|
-
if (!chip.selected) { // 'x' icon. Not currently selected. Go ahead and remove it.
|
|
285
|
-
showMessage(Int`Topic "${chip.label}" has been removed. You can add it back with "add topic".`, 'instructions');
|
|
286
|
-
return this.remove(chip);
|
|
287
|
-
} // radio button icon. Chip is already selected. We are setting the publishing tag.
|
|
288
|
-
event.preventDefault();
|
|
289
|
-
showMessage(Int`Tapping the map will now produce an alert for the "${chip.label}" topic.`, 'instructions');
|
|
290
|
-
if (chip.classList.contains('pub')) return false;
|
|
291
|
-
return this.setPublish(chip.label);
|
|
292
|
-
});
|
|
293
|
-
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.
|
|
294
|
-
event.stopPropagation();
|
|
295
|
-
resetInactivityTimer();
|
|
296
|
-
const chip = event.target;
|
|
297
|
-
if (chip.selected) showMessage(Int`Turning "${chip.label}" alerts back on in the map.`, 'instructions');
|
|
298
|
-
else showMessage(Int`Turning off "${chip.label}" alerts in the map. You can delete the topic altogether with the X.`, 'instructions');
|
|
299
|
-
this.toggleChip(chip);
|
|
300
|
-
Alert.closePopup();
|
|
301
|
-
this.onchange({redisplaySubscribers: false});
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
this.chipset.insertAdjacentHTML("afterbegin", // Chip to add a new hashtag.
|
|
305
|
-
`<div class="combobox">
|
|
306
|
-
<md-filled-text-field class="newtag"
|
|
307
|
-
aria-expanded="false"
|
|
308
|
-
aria-controls="knownTagsListbox"
|
|
309
|
-
aria-autocomplete="list"
|
|
310
|
-
autocomplete="off"
|
|
311
|
-
tabindex="0"
|
|
312
|
-
placeholder="➕${Int`add topic`}"></md-filled-text-field>
|
|
313
|
-
</div>`);
|
|
314
|
-
// I've tried also supplying a datalist, e.g., to supply the mobile keyboard completions, but
|
|
315
|
-
// 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.
|
|
316
377
|
const newtag = this.newtag = this.chipset.querySelector('.newtag');
|
|
317
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
|
+
},
|
|
318
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');
|
|
319
405
|
event.stopPropagation();
|
|
320
406
|
Alert.closePopup();
|
|
321
407
|
resetInactivityTimer();
|
|
@@ -323,7 +409,7 @@ export const Hashtags = {
|
|
|
323
409
|
if (navigator.maxTouchPoints <= 1) { // Only when no multi-touch. On-screen keyboard makes it shoot off the top.
|
|
324
410
|
showMessage(Int`Type a new topic name to see any alerts on the map with this topic.`, 'instructions');
|
|
325
411
|
}
|
|
326
|
-
})
|
|
412
|
+
}),
|
|
327
413
|
newtag.onkeydown = event => {
|
|
328
414
|
if (listbox.classList.contains('hidden')) return;
|
|
329
415
|
const optionCount = listbox.querySelectorAll('.combobox-option').length;
|
|
@@ -331,11 +417,7 @@ export const Hashtags = {
|
|
|
331
417
|
switch (event.key) {
|
|
332
418
|
case 'Enter':
|
|
333
419
|
event.preventDefault();
|
|
334
|
-
|
|
335
|
-
this.acceptTag(); // As is, not from list.
|
|
336
|
-
} else {
|
|
337
|
-
this.selectValue(this.selectors[this.activeIndex]);
|
|
338
|
-
}
|
|
420
|
+
newtag.onchange();
|
|
339
421
|
break;
|
|
340
422
|
|
|
341
423
|
case 'Escape':
|
|
@@ -362,63 +444,6 @@ export const Hashtags = {
|
|
|
362
444
|
this.activeIndex = -1;
|
|
363
445
|
}
|
|
364
446
|
};
|
|
365
|
-
newtag.oninput = () => this.renderSelector(newtag.value);
|
|
366
|
-
// When we click on the listbox, the browser will first blur newtag, and then
|
|
367
|
-
// we would not get the click! So here we delay closing a bit.
|
|
368
|
-
newtag.onblur = () => setTimeout(() => this.closeSelector(), 200);
|
|
369
|
-
},
|
|
370
|
-
remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
|
|
371
|
-
delete this.hashtags[chip.label];
|
|
372
|
-
delete this.canonical2extended[canonicalTag(chip.label)];
|
|
373
|
-
this.onchange({redisplaySubscribers, resetSubscriptions: false});
|
|
374
|
-
},
|
|
375
|
-
toggleChip(chip) { // Switch whether the topic is or is not subscribed.
|
|
376
|
-
// Now selected => hashtags[label] becomes 'pub' (selected and the publisher) and clear old pub
|
|
377
|
-
// NOT now selected => hashtags[label] becomes false (through mechanism as follows)
|
|
378
|
-
// but if publisher => set alt publisher if possible, else remember as backupPublisher
|
|
379
|
-
const label = chip.label;
|
|
380
|
-
|
|
381
|
-
// chip.selected is new state, after clicking.
|
|
382
|
-
if (chip.selected) return this.setPublish(label); // Become publisher, clearing old publisher.
|
|
383
|
-
|
|
384
|
-
// Not selected:
|
|
385
|
-
|
|
386
|
-
// If we're not publisher, just clear. But don't go through getPublish, as that can have side effects.
|
|
387
|
-
if (this.hashtags[label] !== 'pub') return this.hashtags[label] = false;
|
|
388
|
-
|
|
389
|
-
// Also clear, but...
|
|
390
|
-
const subs = this.getSubscribe();
|
|
391
|
-
if (subs.length > 1) { // Find and set alternative publisher if possible.
|
|
392
|
-
const pubIndex = subs.indexOf(label);
|
|
393
|
-
const index = (pubIndex + 1) % subs.length;
|
|
394
|
-
this.setPublish(subs[index]);
|
|
395
|
-
} else {
|
|
396
|
-
// No alternative available. Clear it, but remember for use by getPublish.
|
|
397
|
-
// It will stay .pub styled while toggled, until anything toggles on.
|
|
398
|
-
this.backupPublisher = label;
|
|
399
|
-
}
|
|
400
|
-
return this.hashtags[label] = false;
|
|
401
|
-
},
|
|
402
|
-
getChip(label) { // Handy for scripting, but not otherwise used in app.
|
|
403
|
-
for (const chip of this.chipset.children) {
|
|
404
|
-
if (chip.label === label) return chip;
|
|
405
|
-
}
|
|
406
|
-
return null;
|
|
407
|
-
},
|
|
408
|
-
setPublish(newTag) { // Make this topic be the one to be used when we next publish an alert.
|
|
409
|
-
// newTag will be marked for publishing (in this.hashtags and element style)
|
|
410
|
-
// Old publish tag (if any) will be set back to merely be subscribed (in same)
|
|
411
|
-
let oldTag = this.getPublish();
|
|
412
|
-
const backup = this.backupPublisher;
|
|
413
|
-
if (oldTag) this.hashtags[oldTag] = true; // true (instead of 'pub')
|
|
414
|
-
else if (backup) oldTag = backup;
|
|
415
|
-
this.backupPublisher = false;
|
|
416
|
-
this.hashtags[newTag] = 'pub';
|
|
417
|
-
for (const chip of this.chipset.children) {
|
|
418
|
-
if (chip.label === newTag) chip.classList.add('pub');
|
|
419
|
-
else if (chip.label === oldTag) chip.classList.remove('pub');
|
|
420
|
-
}
|
|
421
|
-
return oldTag;
|
|
422
447
|
}
|
|
423
448
|
};
|
|
424
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
|
|
27
|
-
export { networkPromise
|
|
28
|
-
|
|
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;
|
|
@@ -121,7 +113,7 @@ function noteNotificationPermission(permission) { // Update permission controls/
|
|
|
121
113
|
}
|
|
122
114
|
if (showNotifications.checked === notificationsPreviouslyAllowed) return;
|
|
123
115
|
notificationsPreviouslyAllowed = showNotifications.checked;
|
|
124
|
-
Alert.
|
|
116
|
+
Alert.updateSubscriptions('stickyChange');
|
|
125
117
|
}
|
|
126
118
|
clickTip(showNotifications.parentElement, Int`Enable local ${osName()} notifcations for map alerts, without going through any servers. Requires that the app be running.`, event => {
|
|
127
119
|
resetInactivityTimer();
|
|
@@ -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(
|
|
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.
|
|
@@ -192,35 +187,21 @@ document.querySelector('#correspondentContainer md-outlined-text-field').onclick
|
|
|
192
187
|
event.stopPropagation();
|
|
193
188
|
};
|
|
194
189
|
|
|
195
|
-
|
|
196
|
-
event
|
|
190
|
+
export function shareMap(event) {
|
|
191
|
+
event?.stopPropagation();
|
|
197
192
|
share({text: "CivilDefense.io", url: getShareableURL().href });
|
|
198
|
-
}
|
|
193
|
+
}
|
|
194
|
+
clickTip('#share', Int`${osName()} share a link to this map and topics, with picture of map.`, shareMap);
|
|
199
195
|
|
|
200
196
|
clickTip('#recenterButton', Int`Recenter the map to where you are in the world.`, recenterMap);
|
|
201
197
|
|
|
202
|
-
function checkOnline() { //true if online and visible, else cancel
|
|
198
|
+
function checkOnline() { //true if online and visible, else cancel inactivityTimeout, and show "offline"
|
|
203
199
|
//console.log('checkOnline', navigator.onLine && !document.hidden);
|
|
204
200
|
if (navigator.onLine && !document.hidden) return true;
|
|
205
|
-
clearTimeout(inactivityTimer);
|
|
206
|
-
clearInterval(reconnectCountdown);
|
|
207
201
|
if (!navigator.onLine) showMessage(Int`No network connection.`, 'error');
|
|
208
202
|
else console.warn('hidden');
|
|
209
203
|
return false;
|
|
210
204
|
}
|
|
211
|
-
function resetReconnectCountdown() { // if !checkOnline each second, show time remaining; at expiration initialize(false)
|
|
212
|
-
console.log('resetReconnectCountdown');
|
|
213
|
-
clearInterval(reconnectCountdown);
|
|
214
|
-
let counter = RETRY_SECONDS;
|
|
215
|
-
reconnectCountdown = setInterval(() => {
|
|
216
|
-
if (!checkOnline()) return null;
|
|
217
|
-
if (counter > 1) return showMessage(Int`Disconnected. Retrying in ` + counter-- + Int` seconds.`, 'error');
|
|
218
|
-
showMessage('');
|
|
219
|
-
console.log('countdown timer expired');
|
|
220
|
-
clearInterval(reconnectCountdown);
|
|
221
|
-
return initialize(false);
|
|
222
|
-
}, 1e3);
|
|
223
|
-
}
|
|
224
205
|
|
|
225
206
|
export let positionWatch;
|
|
226
207
|
let subscribeOneShot;
|
|
@@ -265,7 +246,7 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
|
|
|
265
246
|
if (!subscribeOneShot) return;
|
|
266
247
|
subscribeOneShot = false;
|
|
267
248
|
resetInactivityTimer(false);
|
|
268
|
-
Alert.updateSubscriptions(
|
|
249
|
+
Alert.updateSubscriptions('newNode');
|
|
269
250
|
};
|
|
270
251
|
if (!geolocation) {
|
|
271
252
|
showMessage(Int`Geolocation not supported. Using default location.`, 'error', 'fail');
|
|
@@ -304,6 +285,7 @@ let checking = false; // For debouncing.
|
|
|
304
285
|
// On startup, get last persisted portals list, else the portal we came in on.
|
|
305
286
|
//fixme let portals = new Set(JSON.parse(localStorage.getItem('portals') || `["${new URL('/kdht', window.location).href}"]`));
|
|
306
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.
|
|
307
289
|
// debounce
|
|
308
290
|
// if !checkOnline(), return
|
|
309
291
|
// set network to promise a new Contact, set ondisconnect, and connect.
|
|
@@ -314,6 +296,7 @@ async function initialize(event) { // Ensure there is a network promise and map,
|
|
|
314
296
|
try {
|
|
315
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.
|
|
316
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.
|
|
317
300
|
|
|
318
301
|
// If networkPromise has not yet been set (or cleared by disconnect), we will be subscribing.
|
|
319
302
|
const needsConnection = !networkPromise;
|
|
@@ -359,15 +342,6 @@ async function initialize(event) { // Ensure there is a network promise and map,
|
|
|
359
342
|
// so as not to confuse other nodes that have given up on the unresponsive old GUID.
|
|
360
343
|
showMessage(message, 'error');
|
|
361
344
|
});
|
|
362
|
-
//contact.connect(/*fixme ...portals*/)
|
|
363
|
-
// .then(() => contact.subscribe({ // Add and persist any new portals we haven't heard about.
|
|
364
|
-
// eventName: 'sys:portals',
|
|
365
|
-
// handler: data => {
|
|
366
|
-
// const operation = data.payload ? 'add' : 'delete';
|
|
367
|
-
// const resultSet = portals[operation](data.subject);
|
|
368
|
-
// localStorage.setItem('portals', JSON.stringify([...resultSet]));
|
|
369
|
-
// }
|
|
370
|
-
// }))
|
|
371
345
|
});
|
|
372
346
|
}
|
|
373
347
|
await Agent.initialize();
|
|
@@ -384,6 +358,9 @@ window.addEventListener("appinstalled", async () => {
|
|
|
384
358
|
// In such cases, we need to re-initialize stuff. We could go point by point, but for now, just hit everything.
|
|
385
359
|
location.reload();
|
|
386
360
|
});
|
|
361
|
+
document.body.addEventListener('animationend', event => {
|
|
362
|
+
event.target.style.animation = '';
|
|
363
|
+
});
|
|
387
364
|
|
|
388
365
|
const titleLabel = document.querySelector('#titleLabel');
|
|
389
366
|
titleLabel.textContent = Int([titleLabel.textContent]);
|
|
@@ -32,9 +32,13 @@ export function showMessage(message, type = 'loading', errorObject) { // Show lo
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function flashElement(selector) {
|
|
36
|
+
const element = document.querySelector(selector);
|
|
37
|
+
setTimeout(() => element.style.animation = 'pulse20border ease 2s 1', 100);
|
|
38
|
+
}
|
|
39
|
+
|
|
35
40
|
let yourLocation; // marker
|
|
36
41
|
let lastLatitude, lastLongitude;
|
|
37
|
-
|
|
38
42
|
export function updateLocation(lat, lng, zoom, positionLabel) { // initMap if necessary, and set our position.
|
|
39
43
|
//console.log('updateLocation', lat, lng, map, yourLocation);
|
|
40
44
|
// Can't call getCurrentPosition while watching. So set it here for use in recenterMap.
|
|
@@ -48,15 +52,16 @@ export function updateLocation(lat, lng, zoom, positionLabel) { // initMap if ne
|
|
|
48
52
|
const params = url.searchParams;
|
|
49
53
|
const tags = params.get('tags');
|
|
50
54
|
const tagsArray = tags?.split(',') || [];
|
|
55
|
+
const highlight = params.get('highlight');
|
|
51
56
|
tagsArray.forEach(tag => Hashtags.add(decodeURIComponent(tag)));
|
|
52
57
|
Hashtags.onchange({resetSubscriptions: false}); // Too early to subscribe, but will be done during initialization.
|
|
53
58
|
go({lat: params.get('lat'), lng: params.get('lng'), zoom: params.get('z'), alert: params.get('alert')});
|
|
54
59
|
// We don't need the query parameters now. Get rid of them. They're annoying.
|
|
55
60
|
if (params.size > 0) {
|
|
56
|
-
['tags', 'lat', 'lng', 'z', 'alert'].forEach(key => params.delete(key));
|
|
61
|
+
['tags', 'lat', 'lng', 'z', 'alert', 'highlight'].forEach(key => params.delete(key));
|
|
57
62
|
history.replaceState(null, '', url);
|
|
58
63
|
}
|
|
59
|
-
|
|
64
|
+
if (highlight) flashElement(highlight);
|
|
60
65
|
return;
|
|
61
66
|
}
|
|
62
67
|
// Otherwise just update the yourLocation marker if appropriate (and not update zoom).
|
|
@@ -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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
129
|
+
operator.setSender((nodeTag, id, ...rest) => handlers[id](...rest));
|
|
127
130
|
resolve((methodName, ...rest) => operator[methodName](...rest)); // send()
|
|
128
131
|
}
|
|
129
132
|
});
|