@yz-social/civildefense.io 4.5.8 → 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/about/en.html +1 -1
- package/public/about/es.html +1 -1
- package/public/images/loading.gif +0 -0
- package/public/index.html +106 -107
- package/public/javascripts/agent.js +37 -30
- package/public/javascripts/alert.js +112 -64
- package/public/javascripts/conversation.js +8 -5
- package/public/javascripts/hashtags.js +247 -43
- package/public/javascripts/main.js +6 -2
- package/public/javascripts/map.js +4 -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 +74 -12
- package/server/app.js +34 -23
- package/server/{location.json → location.save.json} +1 -1
- package/spec/axonSpec.js +9 -8
- package/spec/conversationSpec.js +2 -2
- package/public/junk.html +0 -145
|
@@ -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,18 +55,24 @@ 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
|
}
|
|
71
|
+
if (!allKnownHashtags.includes(extended)) {
|
|
72
|
+
allKnownHashtags.push(extended);
|
|
73
|
+
this.sort(allKnownHashtags);
|
|
74
|
+
localStorage.setItem('allKnownHashtags', JSON.stringify(allKnownHashtags));
|
|
75
|
+
}
|
|
50
76
|
return extended;
|
|
51
77
|
},
|
|
52
78
|
getAll() { // List of all the user's hashtags.
|
|
@@ -59,7 +85,13 @@ export const Hashtags = {
|
|
|
59
85
|
return this.hashtags[key] === 'pub';
|
|
60
86
|
},
|
|
61
87
|
getPublish() { // Return the one hashtag to which the user intends to publish.
|
|
62
|
-
|
|
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;
|
|
63
95
|
},
|
|
64
96
|
firstEmoji(tag) { // First emoji that appears in string, else falsy.
|
|
65
97
|
// I would prefer that it take just the first emoji, but that doesn't grab double-wide ones
|
|
@@ -74,9 +106,9 @@ export const Hashtags = {
|
|
|
74
106
|
formatAlert(tag) { // HTML (possibly text) to represent tag as a marker on map.
|
|
75
107
|
return this.firstEmoji(tag) || this.identicon(tag);
|
|
76
108
|
},
|
|
77
|
-
formatPubtag(tag) { // HTML (possibly text) to represent tag with defaulted icon.
|
|
109
|
+
formatPubtag(tag, identiconData = tag) { // HTML (possibly text) to represent tag with defaulted icon.
|
|
78
110
|
const emoji = this.firstEmoji(tag);
|
|
79
|
-
return emoji ? tag : this.identicon(
|
|
111
|
+
return emoji ? tag : this.identicon(identiconData) + tag;
|
|
80
112
|
},
|
|
81
113
|
onchange({redisplaySubscribers = true, highlightPublish = false, resetSubscriptions = true} = {}) { // Update and persist internal data, and update visuals.
|
|
82
114
|
// If redisplaySubscribers, the presence/order may have changed.
|
|
@@ -97,12 +129,136 @@ export const Hashtags = {
|
|
|
97
129
|
<md-icon-button slot="remove-trailing-icon" title="fixme help"><md-icon class="material-icons"></md-icon></md-icon-button>
|
|
98
130
|
</md-filter-chip>`;
|
|
99
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.
|
|
145
|
+
closeSelector() { // Close the autocomplete tag selector
|
|
146
|
+
const { listbox, newtag } = this;
|
|
147
|
+
listbox.classList.toggle('hidden', true);
|
|
148
|
+
newtag.setAttribute('aria-expanded', 'false');
|
|
149
|
+
newtag.removeAttribute('aria-activedescendant');
|
|
150
|
+
newtag.value = '';
|
|
151
|
+
this.activeIndex = -1;
|
|
152
|
+
},
|
|
153
|
+
openSelector() { // Open the autocomplete tag selector
|
|
154
|
+
const { listbox, newtag } = this;
|
|
155
|
+
listbox.classList.toggle('hidden', false);
|
|
156
|
+
newtag?.setAttribute('aria-expanded', 'true');
|
|
157
|
+
},
|
|
158
|
+
setActive(index) { // Highlight the index among selectors, and copy it's value to newtag.
|
|
159
|
+
const { listbox, newtag } = this;
|
|
160
|
+
this.activeIndex = index;
|
|
161
|
+
listbox.querySelectorAll('.combobox-option').forEach((option, optionIndex) => {
|
|
162
|
+
const active = index === optionIndex;
|
|
163
|
+
option.classList.toggle('active', active);
|
|
164
|
+
if (active) {
|
|
165
|
+
option.scrollIntoView({ block: 'nearest' });
|
|
166
|
+
newtag.setAttribute('aria-activedescendant', option.id);
|
|
167
|
+
} else {
|
|
168
|
+
newtag.removeAttribute('aria-activedescendant');
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
newtag.value = this.selectors[this.activeIndex];
|
|
172
|
+
},
|
|
173
|
+
selectValue(value) { // Accept the specified string.
|
|
174
|
+
this.newtag.value = value;
|
|
175
|
+
this.acceptTag();
|
|
176
|
+
},
|
|
177
|
+
acceptTag() { // Add the new hashtag.
|
|
178
|
+
resetInactivityTimer();
|
|
179
|
+
let tag = this.newtag?.value // Get into standard form, but do not strip emoji or case into canonical yet.
|
|
180
|
+
.replace(/^#/, '') // No leading hash
|
|
181
|
+
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
|
|
182
|
+
.normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
|
|
183
|
+
Alert.closePopup();
|
|
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
|
+
}
|
|
193
|
+
tag = this.add(tag); // Might exist, in which case tag might now be extended.
|
|
194
|
+
this.setPublish(tag);
|
|
195
|
+
this.onchange({highlightPublish: true});
|
|
196
|
+
},
|
|
197
|
+
activeIndex: -1,
|
|
198
|
+
selectors: [],
|
|
199
|
+
renderSelector(query) { // Render the autocompletion of query string.
|
|
200
|
+
const { listbox, newtag } = this;
|
|
201
|
+
function escapeRegExp(str) {
|
|
202
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
203
|
+
}
|
|
204
|
+
function highlight(text, query) {
|
|
205
|
+
if (!query) return text;
|
|
206
|
+
const re = new RegExp(`(${escapeRegExp(query)})`, 'ig');
|
|
207
|
+
return text.replace(re, '<mark>$1</mark>');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// TODO: create the elements only when allKnownHashtags changes. Then hide/highlight here.
|
|
211
|
+
listbox.innerHTML = '';
|
|
212
|
+
let matchString = canonicalTag(query);
|
|
213
|
+
this.selectors = allKnownHashtags.filter(item =>
|
|
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)
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
if (matchString) {
|
|
221
|
+
const li = document.createElement('li');
|
|
222
|
+
li.className = 'combobox-empty combobox-option';
|
|
223
|
+
li.textContent = `Create tag "${query}"`;
|
|
224
|
+
li.onpointerdown = event => {
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
event.stopPropagation();
|
|
227
|
+
this.acceptTag();
|
|
228
|
+
};
|
|
229
|
+
listbox.appendChild(li);
|
|
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);
|
|
250
|
+
|
|
251
|
+
this.openSelector();
|
|
252
|
+
},
|
|
253
|
+
sort(tags) { // Sort list of tags in place without regard to leding emoji
|
|
254
|
+
tags.sort((a, b) => stripLeadingEmoji(a).localeCompare(stripLeadingEmoji(b)));
|
|
255
|
+
},
|
|
100
256
|
resetSubscriberDisplay() { // Lay out all the hashtag chips display, including the input for adding new ones.
|
|
101
257
|
this.chipset.innerHTML = '';
|
|
102
258
|
const tags = this.getAll();
|
|
103
259
|
|
|
104
260
|
// Sort alphabetically, ignoring any leading emoji, as these have unexpected orderings.
|
|
105
|
-
|
|
261
|
+
this.sort(tags);
|
|
106
262
|
const reordered = {};
|
|
107
263
|
tags.forEach(tag => reordered[tag] = this.hashtags[tag]);
|
|
108
264
|
this.hashtags = reordered;
|
|
@@ -134,29 +290,71 @@ export const Hashtags = {
|
|
|
134
290
|
else showMessage(Int`Turning off "${chip.label}" alerts in the map. You can delete the topic altogether with the X.`, 'instructions');
|
|
135
291
|
this.toggleChip(chip);
|
|
136
292
|
Alert.closePopup();
|
|
137
|
-
if (chip.selected) this.setPublish(chip.label);
|
|
138
293
|
this.onchange({redisplaySubscribers: false});
|
|
139
294
|
});
|
|
140
295
|
});
|
|
141
296
|
this.chipset.insertAdjacentHTML("afterbegin", // Chip to add a new hashtag.
|
|
142
|
-
`<
|
|
143
|
-
|
|
297
|
+
`<div class="combobox">
|
|
298
|
+
<ul class="combobox-listbox hidden" id="knownTagsListbox" role="listbox"></ul>
|
|
299
|
+
<md-filled-text-field class="newtag"
|
|
300
|
+
aria-expanded="false"
|
|
301
|
+
aria-controls="knownTagsListbox"
|
|
302
|
+
aria-autocomplete="list"
|
|
303
|
+
autocomplete="off"
|
|
304
|
+
tabindex="0"
|
|
305
|
+
placeholder="➕${Int`add topic`}"></md-filled-text-field>
|
|
306
|
+
</div>`);
|
|
307
|
+
// I've tried also supplying a datalist, e.g., to supply the mobile keyboard completions, but
|
|
308
|
+
// I have not been able to get it to work.
|
|
309
|
+
const newtag = this.newtag = this.chipset.querySelector('.newtag');
|
|
310
|
+
const listbox = this.listbox = this.chipset.querySelector('.combobox-listbox');
|
|
311
|
+
clickTip(newtag, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
|
|
144
312
|
event.stopPropagation();
|
|
145
313
|
Alert.closePopup();
|
|
146
|
-
|
|
314
|
+
this.renderSelector('');
|
|
315
|
+
if (navigator.maxTouchPoints <= 1) { // Only when no multi-touch. On-screen keyboard makes it shoot off the top.
|
|
316
|
+
showMessage(Int`Type a new topic name to see any alerts on the map with this topic.`, 'instructions');
|
|
317
|
+
}
|
|
147
318
|
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
319
|
+
newtag.onkeydown = event => {
|
|
320
|
+
if (listbox.classList.contains('hidden')) return;
|
|
321
|
+
const optionCount = listbox.querySelectorAll('.combobox-option').length;
|
|
322
|
+
|
|
323
|
+
switch (event.key) {
|
|
324
|
+
case 'Enter':
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
if (this.activeIndex < 0) {
|
|
327
|
+
this.acceptTag(); // As is, not from list.
|
|
328
|
+
} else {
|
|
329
|
+
this.selectValue(this.selectors[this.activeIndex]);
|
|
330
|
+
}
|
|
331
|
+
break;
|
|
332
|
+
|
|
333
|
+
case 'Escape':
|
|
334
|
+
event.preventDefault();
|
|
335
|
+
this.closeSelector();
|
|
336
|
+
break;
|
|
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
|
+
|
|
348
|
+
case 'Tab':
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
if (this.activeIndex < 0) this.setActive(0);
|
|
351
|
+
else this.selectValue(this.selectors[this.activeIndex]); // Otherwise select what is active.
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
159
354
|
};
|
|
355
|
+
newtag.oninput = () => this.renderSelector(newtag.value);
|
|
356
|
+
newtag.onblur = () => this.closeSelector();
|
|
357
|
+
//newtag.onchange = () => this.acceptTag();
|
|
160
358
|
},
|
|
161
359
|
remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
|
|
162
360
|
delete this.hashtags[chip.label];
|
|
@@ -164,20 +362,28 @@ export const Hashtags = {
|
|
|
164
362
|
this.onchange({redisplaySubscribers, resetSubscriptions: false});
|
|
165
363
|
},
|
|
166
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
|
|
167
368
|
const label = chip.label;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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);
|
|
177
382
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.
|
|
383
|
+
|
|
384
|
+
// No alternative. Clear it, but remember for use by getPublish.
|
|
385
|
+
this.lastRemainingPublisher = label;
|
|
386
|
+
return this.hashtags[label] = false;
|
|
181
387
|
},
|
|
182
388
|
getChip(label) { // Handy for scripting, but not otherwise used in app.
|
|
183
389
|
for (const chip of this.chipset.children) {
|
|
@@ -185,9 +391,9 @@ export const Hashtags = {
|
|
|
185
391
|
}
|
|
186
392
|
return null;
|
|
187
393
|
},
|
|
188
|
-
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.
|
|
189
395
|
const oldTag = this.getPublish();
|
|
190
|
-
this.hashtags[oldTag] =
|
|
396
|
+
if (oldTag) this.hashtags[oldTag] = isOldTagNowSubscribed;
|
|
191
397
|
this.hashtags[newTag] = 'pub';
|
|
192
398
|
for (const chip of this.chipset.children) {
|
|
193
399
|
if (chip.label === oldTag) chip.classList.remove('pub');
|
|
@@ -196,11 +402,9 @@ export const Hashtags = {
|
|
|
196
402
|
return oldTag;
|
|
197
403
|
}
|
|
198
404
|
};
|
|
405
|
+
globalThis.Hashtags = Hashtags; // for debugging
|
|
199
406
|
|
|
200
407
|
// Populate hashtags data and display.
|
|
201
408
|
// First the persisted/default data:
|
|
202
|
-
const persisted = JSON.parse(localStorage.getItem('hashtags') ||
|
|
203
|
-
`{"🍰 ${Int`cake`}": true, "🔥 ${Int`fire`}": true, "🌊 ${Int`flood`}": true, "🆘 ${Int`help`}": "pub", "🧊 ${Int`ice`}": true}`);
|
|
204
|
-
//`{"🎙️ news":"pub", "🎸 classic rock":true, "🎷 jazz":true, "🎵 pop":true, "🎤 hiphop":true, "🎧 edm":true, "🤠 country":true, "🎼 classical":true}`);
|
|
205
|
-
//`{"🍰 ${Int`cake`}": true, "🔥 ${Int`fire`}": true, "🌊 ${Int`flood`}": true, "🆘 ${Int`help`}": "pub", "🧊 ${Int`ice`}": true, "🎙️ news":true, "🎸 classic rock":true, "🎷 jazz":true, "🎵 pop":true, "🎤 hiphop":true, "🎧 edm":true, "🤠 country":true, "🎼 classical":true}`);
|
|
409
|
+
const persisted = JSON.parse(localStorage.getItem('hashtags') || `{"🍰 ${Int`cake`}": true, "🆘 ${Int`help`}": "pub"}`);
|
|
206
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;
|
|
@@ -155,6 +155,8 @@ export function initMap(lat, lng, zoom, positionLabel) { // Set up appropriate z
|
|
|
155
155
|
Alert.openPopup(await Alert.publish({lat, lng}));
|
|
156
156
|
Agent.current.persistPublicMetadata(P2PWebNetwork.regionCode(lat, lng));
|
|
157
157
|
});
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
if (document.querySelector('.leaflet-control-zoom')) { // Not present in mobile
|
|
159
|
+
tooltip('.leaflet-control-zoom-in', Int`Zoom in to show more detail in the map.`);
|
|
160
|
+
tooltip('.leaflet-control-zoom-out', Int`Zoom out to show a larger area in the map.`);
|
|
161
|
+
}
|
|
160
162
|
}
|
|
@@ -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
|
}
|