@yz-social/civildefense.io 4.5.9 → 4.5.16
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/index.js +2 -1
- package/package.json +5 -3
- package/public/images/loading.gif +0 -0
- package/public/index.html +5 -6
- package/public/javascripts/agent.js +37 -30
- package/public/javascripts/alert.js +106 -59
- package/public/javascripts/hashtags.js +121 -84
- package/public/javascripts/main.js +6 -2
- package/public/javascripts/p2pWebNetwork.js +31 -10
- package/public/javascripts/s2.js +7 -3
- package/public/javascripts/service-manager.js +5 -4
- package/public/javascripts/translations.js +1 -0
- package/public/javascripts/versions.js +16 -5
- package/public/service-worker.js +7 -7
- package/public/stylesheets/style.css +5 -4
- package/server/app.js +34 -23
- package/server/{location.json → location.save.json} +1 -1
- package/spec/axonSpec.js +9 -8
- package/public/junk.html +0 -145
- package/public/material-combobox.html +0 -379
|
@@ -5,6 +5,26 @@ import { showMessage } from './map.js';
|
|
|
5
5
|
import { Alert } from './alert.js';
|
|
6
6
|
import { resetInactivityTimer, clickTip } from './main.js';
|
|
7
7
|
|
|
8
|
+
|
|
9
|
+
let allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
|
|
10
|
+
"🍰 cake",
|
|
11
|
+
"🎸 classic rock",
|
|
12
|
+
"🎼 classical",
|
|
13
|
+
"🩷 community support",
|
|
14
|
+
"🤠 country",
|
|
15
|
+
"🎧 edm",
|
|
16
|
+
"🔥 fire",
|
|
17
|
+
"🌊 flood",
|
|
18
|
+
"🆘 help",
|
|
19
|
+
"🎤 hiphop",
|
|
20
|
+
"🧊 ice",
|
|
21
|
+
"🎷 jazz",
|
|
22
|
+
"🎙️ news",
|
|
23
|
+
"👁️ observer corps",
|
|
24
|
+
"🎵 pop",
|
|
25
|
+
"🧰 utility repairs"
|
|
26
|
+
]`);
|
|
27
|
+
|
|
8
28
|
// We subscribe to the cartesian product of the list of non-overlapping cells and all hashes.
|
|
9
29
|
// We publish to just the first of these.
|
|
10
30
|
export const Hashtags = {
|
|
@@ -25,8 +45,8 @@ export const Hashtags = {
|
|
|
25
45
|
// Later, we will allow a user to change what they see on their own device.)
|
|
26
46
|
// 4. While an identicon is displayed, it is the same for all markers of this hashtag, regardless of whether the original posters
|
|
27
47
|
// were using the same emoji as each other.
|
|
28
|
-
hashtags: {},
|
|
29
|
-
canonical2extended: {},
|
|
48
|
+
hashtags: {}, // extended string => true/false/'pub'
|
|
49
|
+
canonical2extended: {}, // canonical string => extended string
|
|
30
50
|
add(label, active = true, updateAlerts = true) { // Ensure label is a hashtag, initialized to active, and if existing, forcing it active.
|
|
31
51
|
// Return our (possibly new) understanding of the extended hashtag.
|
|
32
52
|
// Note that only startup-population of tags from persistence would ever specify active=false.
|
|
@@ -35,15 +55,16 @@ export const Hashtags = {
|
|
|
35
55
|
// (We do not change the emoji of an existing extended.)
|
|
36
56
|
const canonical = canonicalTag(label); // no emoji, lower case.
|
|
37
57
|
const ourExtended = this.canonical2extended[canonical]; // our current version, if any
|
|
38
|
-
const
|
|
39
|
-
const extended =
|
|
40
|
-
if (
|
|
58
|
+
const replaceExisting = !this.firstEmoji(ourExtended);
|
|
59
|
+
const extended = replaceExisting ? label : ourExtended; // full emoji form to use
|
|
60
|
+
if (replaceExisting) {
|
|
41
61
|
active = this.hashtags[canonical] || active;
|
|
62
|
+
allKnownHashtags = allKnownHashtags.filter(tag => canonicalTag(tag) !== canonical);
|
|
42
63
|
delete this.hashtags[ourExtended];
|
|
43
64
|
}
|
|
44
65
|
this.hashtags[extended] ||= active; // If it's 'pub', let it remain so.
|
|
45
66
|
this.canonical2extended[canonical] = extended;
|
|
46
|
-
if (
|
|
67
|
+
if (replaceExisting) {
|
|
47
68
|
this.onchange({resetSubscriptions: false});
|
|
48
69
|
if (updateAlerts) Alert.updateAlerts(canonical, extended);
|
|
49
70
|
}
|
|
@@ -64,7 +85,13 @@ export const Hashtags = {
|
|
|
64
85
|
return this.hashtags[key] === 'pub';
|
|
65
86
|
},
|
|
66
87
|
getPublish() { // Return the one hashtag to which the user intends to publish.
|
|
67
|
-
|
|
88
|
+
let pub = this.getAll().find(key => this.isPublish(key));
|
|
89
|
+
if (!pub) {
|
|
90
|
+
pub = this.lastRemainingPublisher || "🆘 help";
|
|
91
|
+
this.hashtags[pub] = 'pub';
|
|
92
|
+
this.onchange({highlightPublish: true});
|
|
93
|
+
}
|
|
94
|
+
return pub;
|
|
68
95
|
},
|
|
69
96
|
firstEmoji(tag) { // First emoji that appears in string, else falsy.
|
|
70
97
|
// I would prefer that it take just the first emoji, but that doesn't grab double-wide ones
|
|
@@ -102,6 +129,19 @@ export const Hashtags = {
|
|
|
102
129
|
<md-icon-button slot="remove-trailing-icon" title="fixme help"><md-icon class="material-icons"></md-icon></md-icon-button>
|
|
103
130
|
</md-filter-chip>`;
|
|
104
131
|
},
|
|
132
|
+
|
|
133
|
+
// Topic entry, with autocomplete.
|
|
134
|
+
// - When you click the input box, it shows all the topics we know about.
|
|
135
|
+
// This includes topics you already have available, because you might not realize you have them.
|
|
136
|
+
// - As you type, it filters out entries that do not match:
|
|
137
|
+
// - If you happen to start with an emoji and have not yet entered a separating space, it matches against those that use the same emoji.
|
|
138
|
+
// - Otherwise, it matches text, ignoring the emoji, and it highlights the substring that matches.
|
|
139
|
+
// - up/down arrow or tab ==> highlights from among those shown and copies its text to the input box, but not yet accepting it.
|
|
140
|
+
// - But if tab is used when something was already highlighted, then it is accepted (like enter).
|
|
141
|
+
// - click (regardless of highlighting) ==> what you click on is used.
|
|
142
|
+
// - enter:
|
|
143
|
+
// - something highlighted ==> what is highlighted is used.
|
|
144
|
+
// - otherwise => exact contents of input box is used.
|
|
105
145
|
closeSelector() { // Close the autocomplete tag selector
|
|
106
146
|
const { listbox, newtag } = this;
|
|
107
147
|
listbox.classList.toggle('hidden', true);
|
|
@@ -115,7 +155,7 @@ export const Hashtags = {
|
|
|
115
155
|
listbox.classList.toggle('hidden', false);
|
|
116
156
|
newtag?.setAttribute('aria-expanded', 'true');
|
|
117
157
|
},
|
|
118
|
-
setActive(index) {
|
|
158
|
+
setActive(index) { // Highlight the index among selectors, and copy it's value to newtag.
|
|
119
159
|
const { listbox, newtag } = this;
|
|
120
160
|
this.activeIndex = index;
|
|
121
161
|
listbox.querySelectorAll('.combobox-option').forEach((option, optionIndex) => {
|
|
@@ -128,19 +168,28 @@ export const Hashtags = {
|
|
|
128
168
|
newtag.removeAttribute('aria-activedescendant');
|
|
129
169
|
}
|
|
130
170
|
});
|
|
171
|
+
newtag.value = this.selectors[this.activeIndex];
|
|
131
172
|
},
|
|
132
|
-
selectValue(value) {
|
|
173
|
+
selectValue(value) { // Accept the specified string.
|
|
133
174
|
this.newtag.value = value;
|
|
134
175
|
this.acceptTag();
|
|
135
176
|
},
|
|
136
177
|
acceptTag() { // Add the new hashtag.
|
|
137
178
|
resetInactivityTimer();
|
|
138
|
-
let tag = this.newtag?.value
|
|
179
|
+
let tag = this.newtag?.value // Get into standard form, but do not strip emoji or case into canonical yet.
|
|
139
180
|
.replace(/^#/, '') // No leading hash
|
|
140
181
|
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
|
|
141
182
|
.normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
|
|
142
183
|
Alert.closePopup();
|
|
143
184
|
if (!tag) return;
|
|
185
|
+
if (this.firstEmoji(tag)) { // Possibly REPLACE existing with the new tag.
|
|
186
|
+
const canonical = canonicalTag(tag);
|
|
187
|
+
const existingExtended = this.canonical2extended[canonical];
|
|
188
|
+
if (existingExtended !== tag) {
|
|
189
|
+
delete this.canonical2extended[canonical];
|
|
190
|
+
delete this.hashtags[existingExtended];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
144
193
|
tag = this.add(tag); // Might exist, in which case tag might now be extended.
|
|
145
194
|
this.setPublish(tag);
|
|
146
195
|
this.onchange({highlightPublish: true});
|
|
@@ -160,13 +209,17 @@ export const Hashtags = {
|
|
|
160
209
|
|
|
161
210
|
// TODO: create the elements only when allKnownHashtags changes. Then hide/highlight here.
|
|
162
211
|
listbox.innerHTML = '';
|
|
212
|
+
let matchString = canonicalTag(query);
|
|
163
213
|
this.selectors = allKnownHashtags.filter(item =>
|
|
164
|
-
|
|
214
|
+
// Subtle: Starting with emoji will match all/only those tags that start with the same tag. Cool.
|
|
215
|
+
// But because of the way canonicalTag() works, once you type a space after an emoji, we match
|
|
216
|
+
// against only the non-emoji, non-space text.
|
|
217
|
+
item.toLowerCase().includes(matchString)
|
|
165
218
|
);
|
|
166
219
|
|
|
167
|
-
if (
|
|
220
|
+
if (matchString) {
|
|
168
221
|
const li = document.createElement('li');
|
|
169
|
-
li.className = 'combobox-empty';
|
|
222
|
+
li.className = 'combobox-empty combobox-option';
|
|
170
223
|
li.textContent = `Create tag "${query}"`;
|
|
171
224
|
li.onpointerdown = event => {
|
|
172
225
|
event.preventDefault();
|
|
@@ -174,34 +227,28 @@ export const Hashtags = {
|
|
|
174
227
|
this.acceptTag();
|
|
175
228
|
};
|
|
176
229
|
listbox.appendChild(li);
|
|
177
|
-
} else {
|
|
178
|
-
this.selectors.forEach((item, i) => {
|
|
179
|
-
const li = document.createElement('li');
|
|
180
|
-
li.className = 'combobox-option';
|
|
181
|
-
li.id = `tag-option-${i}`;
|
|
182
|
-
li.setAttribute('role', 'option');
|
|
183
|
-
li.innerHTML = this.formatPubtag(highlight(item, query), item);
|
|
184
|
-
li.onpointerdown = event => {
|
|
185
|
-
// pointerdown (not click) so it fires before the field's blur event
|
|
186
|
-
event.preventDefault();
|
|
187
|
-
event.stopPropagation();
|
|
188
|
-
this.selectValue(item);
|
|
189
|
-
};
|
|
190
|
-
listbox.appendChild(li);
|
|
191
|
-
});
|
|
192
|
-
setTimeout(() => { // Needs a tick.
|
|
193
|
-
const width = listbox.clientWidth;
|
|
194
|
-
const max = Math.max(125, width);
|
|
195
|
-
newtag.style.width = max + 'px'; // Set newtag so that input box makes room for floating lis
|
|
196
|
-
}, 50);
|
|
197
230
|
}
|
|
231
|
+
this.selectors.forEach((item, i) => {
|
|
232
|
+
const li = document.createElement('li');
|
|
233
|
+
li.className = 'combobox-option';
|
|
234
|
+
li.id = `tag-option-${i}`;
|
|
235
|
+
li.setAttribute('role', 'option');
|
|
236
|
+
li.innerHTML = this.formatPubtag(highlight(item, matchString), item);
|
|
237
|
+
li.onpointerdown = event => {
|
|
238
|
+
// pointerdown (not click) so it fires before the field's blur event
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
event.stopPropagation();
|
|
241
|
+
this.selectValue(item);
|
|
242
|
+
};
|
|
243
|
+
listbox.appendChild(li);
|
|
244
|
+
});
|
|
245
|
+
setTimeout(() => { // Needs a tick.
|
|
246
|
+
const width = listbox.clientWidth;
|
|
247
|
+
const max = Math.max(125, width);
|
|
248
|
+
newtag.style.width = max + 'px'; // Set newtag so that input box makes room for floating lis
|
|
249
|
+
}, 50);
|
|
198
250
|
|
|
199
251
|
this.openSelector();
|
|
200
|
-
if (this.selectors.length === 1) {
|
|
201
|
-
this.setActive(0);
|
|
202
|
-
} else {
|
|
203
|
-
this.setActive(-1);
|
|
204
|
-
}
|
|
205
252
|
},
|
|
206
253
|
sort(tags) { // Sort list of tags in place without regard to leding emoji
|
|
207
254
|
tags.sort((a, b) => stripLeadingEmoji(a).localeCompare(stripLeadingEmoji(b)));
|
|
@@ -243,7 +290,6 @@ export const Hashtags = {
|
|
|
243
290
|
else showMessage(Int`Turning off "${chip.label}" alerts in the map. You can delete the topic altogether with the X.`, 'instructions');
|
|
244
291
|
this.toggleChip(chip);
|
|
245
292
|
Alert.closePopup();
|
|
246
|
-
if (chip.selected) this.setPublish(chip.label);
|
|
247
293
|
this.onchange({redisplaySubscribers: false});
|
|
248
294
|
});
|
|
249
295
|
});
|
|
@@ -275,20 +321,10 @@ export const Hashtags = {
|
|
|
275
321
|
const optionCount = listbox.querySelectorAll('.combobox-option').length;
|
|
276
322
|
|
|
277
323
|
switch (event.key) {
|
|
278
|
-
case 'ArrowDown':
|
|
279
|
-
event.preventDefault();
|
|
280
|
-
if (optionCount > 0) this.setActive((this.activeIndex + 1) % optionCount);
|
|
281
|
-
break;
|
|
282
|
-
|
|
283
|
-
case 'ArrowUp':
|
|
284
|
-
event.preventDefault();
|
|
285
|
-
if (optionCount > 0) this.setActive((this.activeIndex - 1 + optionCount) % optionCount);
|
|
286
|
-
break;
|
|
287
|
-
|
|
288
324
|
case 'Enter':
|
|
289
325
|
event.preventDefault();
|
|
290
326
|
if (this.activeIndex < 0) {
|
|
291
|
-
this.acceptTag();
|
|
327
|
+
this.acceptTag(); // As is, not from list.
|
|
292
328
|
} else {
|
|
293
329
|
this.selectValue(this.selectors[this.activeIndex]);
|
|
294
330
|
}
|
|
@@ -299,16 +335,26 @@ export const Hashtags = {
|
|
|
299
335
|
this.closeSelector();
|
|
300
336
|
break;
|
|
301
337
|
|
|
338
|
+
case 'ArrowDown':
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
if (optionCount > 0) this.setActive((this.activeIndex + 1) % optionCount);
|
|
341
|
+
break;
|
|
342
|
+
|
|
343
|
+
case 'ArrowUp':
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
if (optionCount > 0) this.setActive((this.activeIndex - 1 + optionCount) % optionCount);
|
|
346
|
+
break;
|
|
347
|
+
|
|
302
348
|
case 'Tab':
|
|
303
349
|
event.preventDefault();
|
|
304
|
-
if (this.activeIndex < 0) this.setActive(0);
|
|
350
|
+
if (this.activeIndex < 0) this.setActive(0);
|
|
305
351
|
else this.selectValue(this.selectors[this.activeIndex]); // Otherwise select what is active.
|
|
306
352
|
break;
|
|
307
353
|
}
|
|
308
354
|
};
|
|
309
355
|
newtag.oninput = () => this.renderSelector(newtag.value);
|
|
310
356
|
newtag.onblur = () => this.closeSelector();
|
|
311
|
-
newtag.onchange = () => this.acceptTag();
|
|
357
|
+
//newtag.onchange = () => this.acceptTag();
|
|
312
358
|
},
|
|
313
359
|
remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
|
|
314
360
|
delete this.hashtags[chip.label];
|
|
@@ -316,20 +362,28 @@ export const Hashtags = {
|
|
|
316
362
|
this.onchange({redisplaySubscribers, resetSubscriptions: false});
|
|
317
363
|
},
|
|
318
364
|
toggleChip(chip) { // Switch whether the topic is or is not subscribed.
|
|
365
|
+
// Now selected => hashtags[label] becomes 'pub' (selected and the publisher) and clear old pub
|
|
366
|
+
// NOT now selected => hashtags[label] becomes false (through mechanism as follows)
|
|
367
|
+
// but if publisher => set alt publisher if possible, else remember as lastActivePublisher
|
|
319
368
|
const label = chip.label;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
369
|
+
|
|
370
|
+
// chip.selected is new state, after clicking.
|
|
371
|
+
if (chip.selected) return this.setPublish(label); // Become publisher, clearing old publisher.
|
|
372
|
+
|
|
373
|
+
// If we're not publisher, just clear. But don't go through getPublish, as that can have side effects.
|
|
374
|
+
if (this.hashtags[label] !== 'pub') return this.hashtags[label] = false;
|
|
375
|
+
|
|
376
|
+
// Find an alternative publisher if possible.
|
|
377
|
+
let subs = this.getSubscribe();
|
|
378
|
+
if (subs.length > 1) {
|
|
379
|
+
let pubIndex = subs.indexOf(label);
|
|
380
|
+
let index = (pubIndex + 1) % subs.length;
|
|
381
|
+
return this.setPublish(subs[index], false);
|
|
329
382
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
this.
|
|
383
|
+
|
|
384
|
+
// No alternative. Clear it, but remember for use by getPublish.
|
|
385
|
+
this.lastRemainingPublisher = label;
|
|
386
|
+
return this.hashtags[label] = false;
|
|
333
387
|
},
|
|
334
388
|
getChip(label) { // Handy for scripting, but not otherwise used in app.
|
|
335
389
|
for (const chip of this.chipset.children) {
|
|
@@ -337,9 +391,9 @@ export const Hashtags = {
|
|
|
337
391
|
}
|
|
338
392
|
return null;
|
|
339
393
|
},
|
|
340
|
-
setPublish(newTag) { // Make this topic be the one to be used when we next publish an alert.
|
|
394
|
+
setPublish(newTag, isOldTagNowSubscribed = true) { // Make this topic be the one to be used when we next publish an alert.
|
|
341
395
|
const oldTag = this.getPublish();
|
|
342
|
-
this.hashtags[oldTag] =
|
|
396
|
+
if (oldTag) this.hashtags[oldTag] = isOldTagNowSubscribed;
|
|
343
397
|
this.hashtags[newTag] = 'pub';
|
|
344
398
|
for (const chip of this.chipset.children) {
|
|
345
399
|
if (chip.label === oldTag) chip.classList.remove('pub');
|
|
@@ -348,26 +402,9 @@ export const Hashtags = {
|
|
|
348
402
|
return oldTag;
|
|
349
403
|
}
|
|
350
404
|
};
|
|
405
|
+
globalThis.Hashtags = Hashtags; // for debugging
|
|
351
406
|
|
|
352
407
|
// Populate hashtags data and display.
|
|
353
408
|
// First the persisted/default data:
|
|
354
|
-
const allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
|
|
355
|
-
"🍰 cake",
|
|
356
|
-
"🎸 classic rock",
|
|
357
|
-
"🎼 classical",
|
|
358
|
-
"🩷 community support",
|
|
359
|
-
"🤠 country",
|
|
360
|
-
"🎧 edm",
|
|
361
|
-
"🔥 fire",
|
|
362
|
-
"🌊 flood",
|
|
363
|
-
"🆘 help",
|
|
364
|
-
"🎤 hiphop",
|
|
365
|
-
"🧊 ice",
|
|
366
|
-
"🎷 jazz",
|
|
367
|
-
"🎙️ news",
|
|
368
|
-
"👁️ observer corps",
|
|
369
|
-
"🎵 pop",
|
|
370
|
-
"🧰 utility repairs"
|
|
371
|
-
]`);
|
|
372
409
|
const persisted = JSON.parse(localStorage.getItem('hashtags') || `{"🍰 ${Int`cake`}": true, "🆘 ${Int`help`}": "pub"}`);
|
|
373
410
|
Object.entries(persisted).forEach(([tag, active]) => Hashtags.add(tag, active, false));
|
|
@@ -8,6 +8,7 @@ import { getPointInCell } from './s2.js';
|
|
|
8
8
|
import { Alert, getShareableURL, share } from './alert.js';
|
|
9
9
|
import { map, showMessage, updateLocation, recenterMap } from './map.js';
|
|
10
10
|
import './service-manager.js'; // Comment this out and kill service-workers for reload-to-get-latest behavior during development.
|
|
11
|
+
window.P2PWebNetwork = P2PWebNetwork;
|
|
11
12
|
|
|
12
13
|
document.getElementById('appVersion').textContent = appVersion;
|
|
13
14
|
document.getElementById('kernelVersion').textContent = P2PWebNetwork.kernelVersion;
|
|
@@ -123,6 +124,9 @@ export function openAbout(event) {
|
|
|
123
124
|
openDisplay('aboutContainer', event);
|
|
124
125
|
noteNotificationPermission(window.Notification?.permission);
|
|
125
126
|
}
|
|
127
|
+
export function closeAbout() {
|
|
128
|
+
document.getElementById('aboutContainer').classList.toggle('hidden', true);
|
|
129
|
+
}
|
|
126
130
|
clickTip('#aboutButton', Int`Information about this app, and options to change notifications or how you appear to others.`, event => { // open about
|
|
127
131
|
Alert.closePopup();
|
|
128
132
|
openAbout(event);
|
|
@@ -243,7 +247,7 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
|
|
|
243
247
|
if (!subscribeOneShot) return;
|
|
244
248
|
subscribeOneShot = false;
|
|
245
249
|
resetInactivityTimer(false);
|
|
246
|
-
Alert.updateSubscriptions(
|
|
250
|
+
Alert.updateSubscriptions({oldKeys: {}}); // This was for a new node, so supply an empty oldSubscriptions.
|
|
247
251
|
};
|
|
248
252
|
if (!geolocation) {
|
|
249
253
|
showMessage(Int`Geolocation not supported. Using default location.`, 'error', 'fail');
|
|
@@ -291,7 +295,7 @@ async function initialize(event) { // Ensure there is a network promise and map,
|
|
|
291
295
|
checking = true;
|
|
292
296
|
try {
|
|
293
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.
|
|
294
|
-
|
|
298
|
+
closeAbout();
|
|
295
299
|
|
|
296
300
|
// If networkPromise has not yet been set (or cleared by disconnect), we will be subscribing.
|
|
297
301
|
const needsConnection = !networkPromise;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
-
import { createNodeIdentity, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from '@axona/protocol';
|
|
2
|
+
import { connect, createNodeIdentity, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from '@axona/protocol';
|
|
3
3
|
import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from '@axona/protocol/std';
|
|
4
|
-
|
|
5
|
-
globalThis.RTCPeerConnection ||= await import('node-datachannel/polyfill').then(ndc => ndc.RTCPeerConnection); // To support @axona/protocol < 4.26
|
|
4
|
+
|
|
6
5
|
if (!Uint8Array.prototype.toBase64) { // NodeJS < 24
|
|
7
6
|
Object.defineProperty(Uint8Array.prototype, 'toBase64', {
|
|
8
7
|
value: function toBase64() { return Buffer.from(this.buffer, this.byteOffset, this.byteLength).toString('base64'); },
|
|
@@ -50,7 +49,7 @@ export class P2PWebNetwork {
|
|
|
50
49
|
});
|
|
51
50
|
//peer.onLog('debug', (...rest) => network.debug('DEBUG', ...rest));
|
|
52
51
|
//peer.onLog('info', (...rest) => network.debug('INFO', ...rest));
|
|
53
|
-
peer.onLog('warn', (...rest) => network.info('WARNING', ...rest));
|
|
52
|
+
peer.onLog('warn', (string, data, ...rest) => (data?.why === 'not-seated') || network.info('WARNING', string, data, ...rest)); //peer.onLog('warn', (...rest) => network.info('WARNING', ...rest));
|
|
54
53
|
peer.onLog('error', (...rest) => network.info('ERROR', ...rest));
|
|
55
54
|
const { peers, ms } = status;
|
|
56
55
|
network.info(`Connected ${peers} connections through ${bridgeUrl} in ${ms.toLocaleString()} ms.`);
|
|
@@ -58,10 +57,11 @@ export class P2PWebNetwork {
|
|
|
58
57
|
return network;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
async disconnect() { // Politely close network connection.
|
|
60
|
+
async disconnect(debugLogger = this.debugLogger) { // Politely close network connection.
|
|
62
61
|
const health = this.peer.health();
|
|
63
62
|
await this.disconnector();
|
|
64
|
-
|
|
63
|
+
if (debugLogger) debugLogger(health);
|
|
64
|
+
else this.shortHealth(health);
|
|
65
65
|
this.resetStatePromises();
|
|
66
66
|
}
|
|
67
67
|
async replicateStorage() { // Let the network know that we might go away without further notice.
|
|
@@ -99,7 +99,8 @@ export class P2PWebNetwork {
|
|
|
99
99
|
img.src = URL.createObjectURL(file);
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
|
-
static async downsampledBlob({blob,
|
|
102
|
+
static async downsampledBlob({blob, maxDimension = 1024, inputType = blob.type,
|
|
103
|
+
outputType = inputType.startsWith('image/') ? 'image/jpeg' : inputType}) { // ONLY IN BROWSERS!
|
|
103
104
|
// Promise a reasonably sized Blob (or File) for a given Blob of type image/*, else blob unchanged.
|
|
104
105
|
if (!blob.type.startsWith('image/')) return blob;
|
|
105
106
|
|
|
@@ -175,6 +176,7 @@ export class P2PWebNetwork {
|
|
|
175
176
|
await this.attachment;
|
|
176
177
|
const topic = {region, name: eventName};
|
|
177
178
|
if (owner) topic.owner = owner;
|
|
179
|
+
this.debug('subscribed', {topic, handler:!!handler});
|
|
178
180
|
if (handler) {
|
|
179
181
|
const callback = async envelope => {
|
|
180
182
|
const {message, deleted, msgId, signerPubkey, topic, ts} = envelope;
|
|
@@ -191,14 +193,14 @@ export class P2PWebNetwork {
|
|
|
191
193
|
}
|
|
192
194
|
}
|
|
193
195
|
static currentPublishIdentity = null;
|
|
194
|
-
async publish({eventName, region, owner, signWith = this.constructor.currentPublishIdentity, issuedTime = Date.now(), killTag, payload, ...rest}) {
|
|
196
|
+
async publish({eventName, region, owner, signWith = this.constructor.currentPublishIdentity, issuedTime = Date.now(), subject, killTag = subject, payload, ...rest}) {
|
|
195
197
|
// Publish data to subscribers of eventName.
|
|
196
198
|
if (killTag && payload) throw new Error(`Specify killTag (${killTag}) or payload ($(JSON.stringify(payload)}), but not both.`);
|
|
197
199
|
await this.attachment; // Get connected.
|
|
198
200
|
const topic = {region, name: eventName};
|
|
199
201
|
if (owner) topic.owner = owner;
|
|
200
202
|
const options = {signWith};
|
|
201
|
-
this.debug('published', {topic, killTag, payload, issuedTime, rest, signWith});
|
|
203
|
+
this.debug('published', {topic, killTag, payload, issuedTime, rest, signWith:signWith.authorId});
|
|
202
204
|
if (payload) return await this.peer.pub(topic, {issuedTime, payload, ...rest}, options);
|
|
203
205
|
// The next would not normally happen, but until since:'latest' works, we need a way to send a null payload and have the handler delete the entry.
|
|
204
206
|
if (!killTag) return await this.peer.pub(topic, {issuedTime, payload, ...rest}, options);
|
|
@@ -233,12 +235,31 @@ export class P2PWebNetwork {
|
|
|
233
235
|
// E.g., a precise location gets anonymized to containing top-level cell center.
|
|
234
236
|
return this.regionCenter(this.regionCode(lat, lng));
|
|
235
237
|
}
|
|
236
|
-
// Todo: Integrate with AxonaPeer's complex logging.
|
|
237
238
|
debug(...rest) { // Add debug logspam.
|
|
238
239
|
this.debugLogger?.(this.nodeIdentity.id, ...rest);
|
|
239
240
|
}
|
|
240
241
|
info(...rest) { // Add debug logspam.
|
|
241
242
|
(this.infoLogger || this.debugLogger)?.(this.nodeIdentity.id, ...rest);
|
|
242
243
|
}
|
|
244
|
+
static short(tag) {
|
|
245
|
+
return tag.slice(0, 8);
|
|
246
|
+
}
|
|
247
|
+
shortHealth(health = this.peer.health()) { // Info-report a short form of health.
|
|
248
|
+
const roots = health.axonRoles.filter(r => r.isRoot);
|
|
249
|
+
this.info(`disconnected with connections ${health.peers.map(P2PWebNetwork.short)}\nand ${roots.length ? `roots ${roots.map(r => P2PWebNetwork.short(r.topic))}.` : 'no roots.'}`);
|
|
250
|
+
}
|
|
251
|
+
inRegionConnections(health = this.peer.health()) {
|
|
252
|
+
const region = this.nodeIdentity.id.slice(0, 2);
|
|
253
|
+
return health.peers.filter(tag => tag.startsWith(region));
|
|
254
|
+
}
|
|
255
|
+
ice(tags = this.peer.health().peers) {
|
|
256
|
+
const ice = {};
|
|
257
|
+
for (const nodeTag of tags) {
|
|
258
|
+
const meshTag = this.transport.webrtc.meshIdFor(BigInt('0x'+nodeTag));
|
|
259
|
+
const peer = this.transport.mesh._peers.get(meshTag);
|
|
260
|
+
ice[P2PWebNetwork.short(nodeTag)] = peer ? `${peer.localCand} => ${peer.remoteCand}` : 'no webrtc connection';
|
|
261
|
+
}
|
|
262
|
+
return ice;
|
|
263
|
+
}
|
|
243
264
|
}
|
|
244
265
|
export default P2PWebNetwork;
|
package/public/javascripts/s2.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { s2 } from 's2js';
|
|
2
2
|
const { cellid, LatLng, Point, Cell, Cap, RegionCoverer } = s2;
|
|
3
|
+
import { cellHex } from './versions.js';
|
|
4
|
+
const { BigInt } = globalThis;
|
|
3
5
|
|
|
4
6
|
// s2 defines non-overlapping cells that completely cover the globe, at several different levels of cell-size.
|
|
5
7
|
// Each cell, at each level, has its own unique id, which we use a pub/sub key.
|
|
@@ -16,7 +18,7 @@ const MAX_MAP_LEVEL = 17; // The max level that findCoverCellsByCenterAndRadius
|
|
|
16
18
|
|
|
17
19
|
const EARTH_RADIUS_METERS = 6371e3;
|
|
18
20
|
|
|
19
|
-
export function getPointInCell(cellId) { // answer [lat, lng] in degrees
|
|
21
|
+
export function getPointInCell(cellId) { // answer [lat, lng] in degrees from a BigInt
|
|
20
22
|
let {lat, lng} = s2.cellid.latLng(cellId);
|
|
21
23
|
const degrees = 180 / Math.PI;
|
|
22
24
|
lat *= degrees;
|
|
@@ -24,6 +26,10 @@ export function getPointInCell(cellId) { // answer [lat, lng] in degrees.
|
|
|
24
26
|
return [lat, lng];
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
export function getSubdivision(hexString) {
|
|
30
|
+
return cellid.children(BigInt('0x' + hexString)).map(childCell => cellHex(childCell));
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
// Return a list of the cell ids that contain the point.
|
|
28
34
|
export function getContainingCells(lat, lng) {
|
|
29
35
|
const userLatLng = LatLng.fromDegrees(lat, lng);
|
|
@@ -73,5 +79,3 @@ export function findCoverCellsByCenterAndRadius(lat, lng, interestRadiusMeters)
|
|
|
73
79
|
const r = Cap.fromCenterAngle(point, interestRadiusMeters / EARTH_RADIUS_METERS);
|
|
74
80
|
return rc.covering(r);
|
|
75
81
|
}
|
|
76
|
-
|
|
77
|
-
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const { Request, Response, URL, localStorage, BroadcastChannel } = globalThis;
|
|
2
2
|
import { appVersion } from './versions.js';
|
|
3
|
-
import { resetInactivityTimer, clickTip } from './main.js';
|
|
3
|
+
import { resetInactivityTimer, clickTip, closeAbout } from './main.js';
|
|
4
4
|
import { openDisplay } from './display.js';
|
|
5
|
-
import { go } from './alert.js';
|
|
5
|
+
import { go, getShareableURL } from './alert.js';
|
|
6
6
|
import { Int } from './translations.js';
|
|
7
7
|
|
|
8
8
|
/*
|
|
@@ -73,12 +73,13 @@ function newVersionAvailable(newVersion) {
|
|
|
73
73
|
}
|
|
74
74
|
async function installUpdate(event, newVersion) {
|
|
75
75
|
event.stopPropagation();
|
|
76
|
-
event.target.textContent =
|
|
76
|
+
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.
|
|
77
77
|
event.target.disabled = true;
|
|
78
78
|
await caches.delete(appVersion); // Must be before cacheSource, or we'll just recache the same files!
|
|
79
79
|
await cacheSource(newVersion);
|
|
80
|
+
closeAbout();
|
|
80
81
|
// Reload, but convince all browsers to re-"fetch" (through the new service worker that is now running).
|
|
81
|
-
const url =
|
|
82
|
+
const url = getShareableURL(null, []);
|
|
82
83
|
url.searchParams.set('v', newVersion); // Preserving any other searchParams.
|
|
83
84
|
// For any other tabs in THIS browser:
|
|
84
85
|
new BroadcastChannel('site_control').postMessage({method: 'reload', params: url.href});
|
|
@@ -52,6 +52,7 @@ const translations = {
|
|
|
52
52
|
['does not support notifications on WebViews embedded in other programs. Please use CivilDefense.io in native']: {es: "no admite notificaciones en WebViews integrados en otros programas. Por favor, utilice CivilDefense.io en el navegador nativo"},
|
|
53
53
|
['Apple only supports mobile notifications for web pages that have been']: {es: "Apple solo admite notificaciones móviles para páginas web que hayan sido"},
|
|
54
54
|
['installed to the home screen']: {es: "instaladoi en la pantalla de inicio"},
|
|
55
|
+
['Installing...']: {es: "Instalando..."},
|
|
55
56
|
['Enable notifications']: {es: "Habilitar notificaciones"},
|
|
56
57
|
['Allow notifications']: {es: "Permitir notificaciones"},
|
|
57
58
|
['Permissions can be re-enabled through the']: {es: "Los permisos se pueden volver a habilitar a través de"},
|
|
@@ -3,10 +3,11 @@ export const appVersion = pkg.version; // Overall semver of app. Used in display
|
|
|
3
3
|
export const dataVersion = globalThis.process?.env.EVENT_VERSION || appVersion.split('.')[0]; // Compatability differentiator used below.
|
|
4
4
|
|
|
5
5
|
export function stripLeadingEmoji(string) { // Return string without any leading emoji (which might be of varying
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
// length) followed by an optional emoji break character and any whitespace.
|
|
7
|
+
// {Extended_Pictographic} is often recommended instead of {Emoji} as the latter includes numbers
|
|
8
|
+
// and symbols. However, the former misses, e.g., the flag emojis.
|
|
9
|
+
return string.replace(/^\p{Emoji}*\uFE0F?\s*/u, '') ||
|
|
10
|
+
(string.endsWith(' ') ? '' : string); // All emoji counts, but if ending with space, it is the start of an emoji + \s + tag for which just the tag counts.
|
|
10
11
|
};
|
|
11
12
|
export function canonicalTag(tag) { // A string representing tag, without the (leading) emoji if any.
|
|
12
13
|
return stripLeadingEmoji(tag).toLowerCase();
|
|
@@ -18,6 +19,16 @@ export function agentPersistKey(metadataType, agentTag) { // A label for looking
|
|
|
18
19
|
export function agentTopic(metadataType, agentTag) { // Return topic name for public info about agent specified by tag.
|
|
19
20
|
return `public:${dataVersion}:${agentPersistKey(metadataType, agentTag)}`;
|
|
20
21
|
}
|
|
22
|
+
export function cellHex(cellid) { // Convert cellid BigInt to properly padded hex.
|
|
23
|
+
return cellid.toString(16).padStart(16, '0');
|
|
24
|
+
}
|
|
21
25
|
export function alertTopic(cellid, tag) { // Return topic name for public info about specified tag in cellid.
|
|
22
|
-
return `civildefense.io:${dataVersion}:${cellid}:${canonicalTag(tag)}`;
|
|
26
|
+
return `civildefense.io:${dataVersion}:${cellHex(cellid)}:${canonicalTag(tag)}`;
|
|
27
|
+
}
|
|
28
|
+
export function topicCell(topicName) { // Return the cell that is baked in to the topic.
|
|
29
|
+
// Note that canonicalTag(tag) may contain a colon, but dataVersion must not.
|
|
30
|
+
return topicName.match(/civildefense.io:[^:]+:([a-z0-9]+):/)[1];
|
|
31
|
+
}
|
|
32
|
+
export function topicRegion(topicName) { // Return the region that is baked in to the topic.
|
|
33
|
+
return '0x' + topicCell(topicName).slice(0, 2);
|
|
23
34
|
}
|
package/public/service-worker.js
CHANGED
|
@@ -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 = '4.5.
|
|
3
|
+
const serviceVersion = '4.5.16';
|
|
4
4
|
|
|
5
5
|
const cacheList = [ // The files we need.
|
|
6
6
|
"/",
|
|
@@ -172,13 +172,13 @@ async function cacheSource(version, event) { // Cache source in the given versio
|
|
|
172
172
|
await Promise.all([
|
|
173
173
|
// These are referenced within material web, but missing. Turns out we don't need them,
|
|
174
174
|
// but let's cache empty responses to keep the console cleaner.
|
|
175
|
-
"https://esm.run/npm/lit@3.3.
|
|
175
|
+
"https://esm.run/npm/lit@3.3.3/+esm",
|
|
176
176
|
"https://esm.run/npm/tslib@2.8.1/+esm",
|
|
177
|
-
"https://esm.run/npm/lit@3.3.
|
|
178
|
-
"https://esm.run/npm/lit@3.3.
|
|
179
|
-
"https://esm.run/npm/lit@3.3.
|
|
180
|
-
"https://esm.run/npm/lit@3.3.
|
|
181
|
-
"https://esm.run/npm/lit@3.3.
|
|
177
|
+
"https://esm.run/npm/lit@3.3.3/static-html.js/+esm",
|
|
178
|
+
"https://esm.run/npm/lit@3.3.3/decorators.js/+esm",
|
|
179
|
+
"https://esm.run/npm/lit@3.3.3/directives/style-map.js/+esm",
|
|
180
|
+
"https://esm.run/npm/lit@3.3.3/directives/class-map.js/+esm",
|
|
181
|
+
"https://esm.run/npm/lit@3.3.3/directives/when.js/+esm",
|
|
182
182
|
"https://esm.run/npm/lit@3.3.1/directives/live.js/+esm",
|
|
183
183
|
].map(url => cache.put(new Request(url),
|
|
184
184
|
new Response("", {headers: { "Content-Type": "text/javascript" }}))));
|
|
@@ -323,6 +323,7 @@ md-filter-chip minidenticon-svg {
|
|
|
323
323
|
md-outlined-icon-button minidenticon-svg, md-outlined-icon-button img {
|
|
324
324
|
height: 40px;
|
|
325
325
|
width: 40px;
|
|
326
|
+
object-fit: cover;
|
|
326
327
|
border-radius: 12px;
|
|
327
328
|
}
|
|
328
329
|
.alert span minidenticon-svg {
|
|
@@ -388,9 +389,9 @@ md-menu {
|
|
|
388
389
|
md-outlined-button {
|
|
389
390
|
padding: 10px;
|
|
390
391
|
}
|
|
391
|
-
.attribution > div:last-child > span {
|
|
392
|
-
|
|
393
|
-
}
|
|
392
|
+
/* .attribution > div:last-child > span { */
|
|
393
|
+
/* text-wrap: nowrap; */
|
|
394
|
+
/* } */
|
|
394
395
|
md-menu-item minidenticon-svg {
|
|
395
396
|
display: inline-block;
|
|
396
397
|
height: 20px;
|
|
@@ -424,7 +425,7 @@ md-menu-item minidenticon-svg {
|
|
|
424
425
|
.attribution-metadata {
|
|
425
426
|
display: flex;
|
|
426
427
|
flex-direction: column;
|
|
427
|
-
align-items: end;
|
|
428
|
+
/* align-items: end; */
|
|
428
429
|
}
|
|
429
430
|
.alert {
|
|
430
431
|
font-family: roboto;
|