@yz-social/civildefense.io 4.5.9 → 4.5.21
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 +6 -3
- package/public/images/loading.gif +0 -0
- package/public/index.html +6 -6
- package/public/javascripts/agent.js +72 -48
- package/public/javascripts/alert.js +126 -63
- package/public/javascripts/hashtags.js +148 -92
- package/public/javascripts/main.js +9 -5
- package/public/javascripts/map.js +1 -1
- package/public/javascripts/p2pWebNetwork.js +41 -16
- package/public/javascripts/protocol.js +168 -0
- package/public/javascripts/pubsub.js +124 -0
- package/public/javascripts/s2.js +36 -41
- 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 +25 -10
- package/public/stylesheets/style.css +20 -13
- package/server/app.js +42 -28
- package/server/{location.json → location.save.json} +1 -1
- package/server/websocket.js +46 -0
- package/spec/axonSpec.js +9 -8
- package/public/junk.html +0 -145
- package/public/material-combobox.html +0 -379
|
@@ -5,6 +5,27 @@ import { showMessage } from './map.js';
|
|
|
5
5
|
import { Alert } from './alert.js';
|
|
6
6
|
import { resetInactivityTimer, clickTip } from './main.js';
|
|
7
7
|
|
|
8
|
+
|
|
9
|
+
const help = `🆘 ${Int`help`}`;
|
|
10
|
+
let allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
|
|
11
|
+
"🍰 cake",
|
|
12
|
+
"🎸 classic rock",
|
|
13
|
+
"🎼 classical",
|
|
14
|
+
"🩷 community support",
|
|
15
|
+
"🤠 country",
|
|
16
|
+
"🎧 edm",
|
|
17
|
+
"🔥 fire",
|
|
18
|
+
"🌊 flood",
|
|
19
|
+
"${help}",
|
|
20
|
+
"🎤 hiphop",
|
|
21
|
+
"🧊 ice",
|
|
22
|
+
"🎷 jazz",
|
|
23
|
+
"🎙️ news",
|
|
24
|
+
"👁️ observer corps",
|
|
25
|
+
"🎵 pop",
|
|
26
|
+
"🧰 utility repairs"
|
|
27
|
+
]`);
|
|
28
|
+
|
|
8
29
|
// We subscribe to the cartesian product of the list of non-overlapping cells and all hashes.
|
|
9
30
|
// We publish to just the first of these.
|
|
10
31
|
export const Hashtags = {
|
|
@@ -25,8 +46,8 @@ export const Hashtags = {
|
|
|
25
46
|
// Later, we will allow a user to change what they see on their own device.)
|
|
26
47
|
// 4. While an identicon is displayed, it is the same for all markers of this hashtag, regardless of whether the original posters
|
|
27
48
|
// were using the same emoji as each other.
|
|
28
|
-
hashtags: {},
|
|
29
|
-
canonical2extended: {},
|
|
49
|
+
hashtags: {}, // extended string => true/false/'pub'
|
|
50
|
+
canonical2extended: {}, // canonical string => extended string
|
|
30
51
|
add(label, active = true, updateAlerts = true) { // Ensure label is a hashtag, initialized to active, and if existing, forcing it active.
|
|
31
52
|
// Return our (possibly new) understanding of the extended hashtag.
|
|
32
53
|
// Note that only startup-population of tags from persistence would ever specify active=false.
|
|
@@ -35,15 +56,16 @@ export const Hashtags = {
|
|
|
35
56
|
// (We do not change the emoji of an existing extended.)
|
|
36
57
|
const canonical = canonicalTag(label); // no emoji, lower case.
|
|
37
58
|
const ourExtended = this.canonical2extended[canonical]; // our current version, if any
|
|
38
|
-
const
|
|
39
|
-
const extended =
|
|
40
|
-
if (
|
|
59
|
+
const replaceExisting = !this.firstEmoji(ourExtended);
|
|
60
|
+
const extended = replaceExisting ? label : ourExtended; // full emoji form to use
|
|
61
|
+
if (replaceExisting) {
|
|
41
62
|
active = this.hashtags[canonical] || active;
|
|
63
|
+
allKnownHashtags = allKnownHashtags.filter(tag => canonicalTag(tag) !== canonical);
|
|
42
64
|
delete this.hashtags[ourExtended];
|
|
43
65
|
}
|
|
44
66
|
this.hashtags[extended] ||= active; // If it's 'pub', let it remain so.
|
|
45
67
|
this.canonical2extended[canonical] = extended;
|
|
46
|
-
if (
|
|
68
|
+
if (replaceExisting) {
|
|
47
69
|
this.onchange({resetSubscriptions: false});
|
|
48
70
|
if (updateAlerts) Alert.updateAlerts(canonical, extended);
|
|
49
71
|
}
|
|
@@ -60,11 +82,22 @@ export const Hashtags = {
|
|
|
60
82
|
getSubscribe() { // Return a list of the hashtags to which the user intendeds to subscribe.
|
|
61
83
|
return this.getAll().filter(tag => this.hashtags[tag]);
|
|
62
84
|
},
|
|
85
|
+
isSubscribed(key) {
|
|
86
|
+
return this.hashtags[key];
|
|
87
|
+
},
|
|
63
88
|
isPublish(key) {
|
|
64
89
|
return this.hashtags[key] === 'pub';
|
|
65
90
|
},
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
backupPublisher: false,
|
|
92
|
+
getPublish(force = false) { // Return the one hashtag to which the user intends to publish.
|
|
93
|
+
// If force and no publisher, setPublisher to backup and return it.
|
|
94
|
+
let pub = this.getAll().find(key => this.isPublish(key));
|
|
95
|
+
if (!pub && force) {
|
|
96
|
+
pub = this.backupPublisher || help;
|
|
97
|
+
this.setPublish(pub);
|
|
98
|
+
this.onchange({highlightPublish: true});
|
|
99
|
+
}
|
|
100
|
+
return pub;
|
|
68
101
|
},
|
|
69
102
|
firstEmoji(tag) { // First emoji that appears in string, else falsy.
|
|
70
103
|
// I would prefer that it take just the first emoji, but that doesn't grab double-wide ones
|
|
@@ -88,8 +121,10 @@ export const Hashtags = {
|
|
|
88
121
|
if (redisplaySubscribers) this.resetSubscriberDisplay();
|
|
89
122
|
localStorage.setItem('hashtags', JSON.stringify(this.hashtags));
|
|
90
123
|
if (resetSubscriptions) {
|
|
91
|
-
|
|
124
|
+
// We destroy unsubscribed markers right away, because we don't want the user to have to wait and wonder why they're still displayed.
|
|
125
|
+
// If there are alerts in flight, they will be rejected by Alert initialize because we will have already turned off the sub.
|
|
92
126
|
Object.values(Alert.items).forEach(wrapper => this.hashtags[wrapper.hashtag] || wrapper.destroy());
|
|
127
|
+
Alert.updateSubscriptions();
|
|
93
128
|
}
|
|
94
129
|
},
|
|
95
130
|
chipset: document.body.querySelector('.watching-hashtags'), // Element containing the user's chips.
|
|
@@ -99,9 +134,22 @@ export const Hashtags = {
|
|
|
99
134
|
${active === 'pub' ? 'class="pub"' : ''}
|
|
100
135
|
${active ? ' selected' : ''}
|
|
101
136
|
>${this.firstEmoji(label) ? '' : this.identicon(label, 'selected-icon')}
|
|
102
|
-
<md-icon-button slot="remove-trailing-icon"
|
|
137
|
+
<md-icon-button slot="remove-trailing-icon"><md-icon class="material-icons"></md-icon></md-icon-button>
|
|
103
138
|
</md-filter-chip>`;
|
|
104
139
|
},
|
|
140
|
+
|
|
141
|
+
// Topic entry, with autocomplete.
|
|
142
|
+
// - When you click the input box, it shows all the topics we know about.
|
|
143
|
+
// This includes topics you already have available, because you might not realize you have them.
|
|
144
|
+
// - As you type, it filters out entries that do not match:
|
|
145
|
+
// - 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.
|
|
146
|
+
// - Otherwise, it matches text, ignoring the emoji, and it highlights the substring that matches.
|
|
147
|
+
// - up/down arrow or tab ==> highlights from among those shown and copies its text to the input box, but not yet accepting it.
|
|
148
|
+
// - But if tab is used when something was already highlighted, then it is accepted (like enter).
|
|
149
|
+
// - click (regardless of highlighting) ==> what you click on is used.
|
|
150
|
+
// - enter:
|
|
151
|
+
// - something highlighted ==> what is highlighted is used.
|
|
152
|
+
// - otherwise => exact contents of input box is used.
|
|
105
153
|
closeSelector() { // Close the autocomplete tag selector
|
|
106
154
|
const { listbox, newtag } = this;
|
|
107
155
|
listbox.classList.toggle('hidden', true);
|
|
@@ -115,7 +163,7 @@ export const Hashtags = {
|
|
|
115
163
|
listbox.classList.toggle('hidden', false);
|
|
116
164
|
newtag?.setAttribute('aria-expanded', 'true');
|
|
117
165
|
},
|
|
118
|
-
setActive(index) {
|
|
166
|
+
setActive(index) { // Highlight the index among selectors, and copy it's value to newtag.
|
|
119
167
|
const { listbox, newtag } = this;
|
|
120
168
|
this.activeIndex = index;
|
|
121
169
|
listbox.querySelectorAll('.combobox-option').forEach((option, optionIndex) => {
|
|
@@ -128,19 +176,28 @@ export const Hashtags = {
|
|
|
128
176
|
newtag.removeAttribute('aria-activedescendant');
|
|
129
177
|
}
|
|
130
178
|
});
|
|
179
|
+
newtag.value = this.selectors[this.activeIndex];
|
|
131
180
|
},
|
|
132
|
-
selectValue(value) {
|
|
181
|
+
selectValue(value) { // Accept the specified string.
|
|
133
182
|
this.newtag.value = value;
|
|
134
183
|
this.acceptTag();
|
|
135
184
|
},
|
|
136
185
|
acceptTag() { // Add the new hashtag.
|
|
137
186
|
resetInactivityTimer();
|
|
138
|
-
let tag = this.newtag?.value
|
|
187
|
+
let tag = this.newtag?.value // Get into standard form, but do not strip emoji or case into canonical yet.
|
|
139
188
|
.replace(/^#/, '') // No leading hash
|
|
140
189
|
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
|
|
141
190
|
.normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
|
|
142
191
|
Alert.closePopup();
|
|
143
192
|
if (!tag) return;
|
|
193
|
+
if (this.firstEmoji(tag)) { // Possibly REPLACE existing with the new tag.
|
|
194
|
+
const canonical = canonicalTag(tag);
|
|
195
|
+
const existingExtended = this.canonical2extended[canonical];
|
|
196
|
+
if (existingExtended !== tag) {
|
|
197
|
+
delete this.canonical2extended[canonical];
|
|
198
|
+
delete this.hashtags[existingExtended];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
144
201
|
tag = this.add(tag); // Might exist, in which case tag might now be extended.
|
|
145
202
|
this.setPublish(tag);
|
|
146
203
|
this.onchange({highlightPublish: true});
|
|
@@ -160,13 +217,17 @@ export const Hashtags = {
|
|
|
160
217
|
|
|
161
218
|
// TODO: create the elements only when allKnownHashtags changes. Then hide/highlight here.
|
|
162
219
|
listbox.innerHTML = '';
|
|
220
|
+
let matchString = canonicalTag(query);
|
|
163
221
|
this.selectors = allKnownHashtags.filter(item =>
|
|
164
|
-
|
|
222
|
+
// Subtle: Starting with emoji will match all/only those tags that start with the same tag. Cool.
|
|
223
|
+
// But because of the way canonicalTag() works, once you type a space after an emoji, we match
|
|
224
|
+
// against only the non-emoji, non-space text.
|
|
225
|
+
item.toLowerCase().includes(matchString)
|
|
165
226
|
);
|
|
166
227
|
|
|
167
|
-
if (
|
|
228
|
+
if (matchString) {
|
|
168
229
|
const li = document.createElement('li');
|
|
169
|
-
li.className = 'combobox-empty';
|
|
230
|
+
li.className = 'combobox-empty combobox-option';
|
|
170
231
|
li.textContent = `Create tag "${query}"`;
|
|
171
232
|
li.onpointerdown = event => {
|
|
172
233
|
event.preventDefault();
|
|
@@ -174,34 +235,30 @@ export const Hashtags = {
|
|
|
174
235
|
this.acceptTag();
|
|
175
236
|
};
|
|
176
237
|
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
238
|
}
|
|
239
|
+
this.selectors.forEach((item, i) => {
|
|
240
|
+
const li = document.createElement('li');
|
|
241
|
+
li.className = 'combobox-option';
|
|
242
|
+
li.id = `tag-option-${i}`;
|
|
243
|
+
li.setAttribute('role', 'option');
|
|
244
|
+
li.innerHTML = this.formatPubtag(highlight(item, matchString), item);
|
|
245
|
+
li.onclick = event => {
|
|
246
|
+
// pointerdown would fire before the text field's blur event,
|
|
247
|
+
// so we would not have to delay that. But then we would not
|
|
248
|
+
// scroll properly by touch drag.
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
event.stopPropagation();
|
|
251
|
+
this.selectValue(item);
|
|
252
|
+
};
|
|
253
|
+
listbox.appendChild(li);
|
|
254
|
+
});
|
|
255
|
+
setTimeout(() => { // Needs a tick.
|
|
256
|
+
const width = listbox.clientWidth;
|
|
257
|
+
const max = Math.max(125, width);
|
|
258
|
+
newtag.style.width = max + 'px'; // Set newtag so that input box makes room for floating lis
|
|
259
|
+
}, 50);
|
|
198
260
|
|
|
199
261
|
this.openSelector();
|
|
200
|
-
if (this.selectors.length === 1) {
|
|
201
|
-
this.setActive(0);
|
|
202
|
-
} else {
|
|
203
|
-
this.setActive(-1);
|
|
204
|
-
}
|
|
205
262
|
},
|
|
206
263
|
sort(tags) { // Sort list of tags in place without regard to leding emoji
|
|
207
264
|
tags.sort((a, b) => stripLeadingEmoji(a).localeCompare(stripLeadingEmoji(b)));
|
|
@@ -243,13 +300,11 @@ export const Hashtags = {
|
|
|
243
300
|
else showMessage(Int`Turning off "${chip.label}" alerts in the map. You can delete the topic altogether with the X.`, 'instructions');
|
|
244
301
|
this.toggleChip(chip);
|
|
245
302
|
Alert.closePopup();
|
|
246
|
-
if (chip.selected) this.setPublish(chip.label);
|
|
247
303
|
this.onchange({redisplaySubscribers: false});
|
|
248
304
|
});
|
|
249
305
|
});
|
|
250
306
|
this.chipset.insertAdjacentHTML("afterbegin", // Chip to add a new hashtag.
|
|
251
307
|
`<div class="combobox">
|
|
252
|
-
<ul class="combobox-listbox hidden" id="knownTagsListbox" role="listbox"></ul>
|
|
253
308
|
<md-filled-text-field class="newtag"
|
|
254
309
|
aria-expanded="false"
|
|
255
310
|
aria-controls="knownTagsListbox"
|
|
@@ -261,7 +316,7 @@ export const Hashtags = {
|
|
|
261
316
|
// I've tried also supplying a datalist, e.g., to supply the mobile keyboard completions, but
|
|
262
317
|
// I have not been able to get it to work.
|
|
263
318
|
const newtag = this.newtag = this.chipset.querySelector('.newtag');
|
|
264
|
-
const listbox = this.listbox =
|
|
319
|
+
const listbox = this.listbox = document.querySelector('.combobox-listbox');
|
|
265
320
|
clickTip(newtag, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
|
|
266
321
|
event.stopPropagation();
|
|
267
322
|
Alert.closePopup();
|
|
@@ -275,20 +330,10 @@ export const Hashtags = {
|
|
|
275
330
|
const optionCount = listbox.querySelectorAll('.combobox-option').length;
|
|
276
331
|
|
|
277
332
|
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
333
|
case 'Enter':
|
|
289
334
|
event.preventDefault();
|
|
290
335
|
if (this.activeIndex < 0) {
|
|
291
|
-
this.acceptTag();
|
|
336
|
+
this.acceptTag(); // As is, not from list.
|
|
292
337
|
} else {
|
|
293
338
|
this.selectValue(this.selectors[this.activeIndex]);
|
|
294
339
|
}
|
|
@@ -299,15 +344,27 @@ export const Hashtags = {
|
|
|
299
344
|
this.closeSelector();
|
|
300
345
|
break;
|
|
301
346
|
|
|
347
|
+
case 'ArrowDown':
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
if (optionCount > 0) this.setActive((this.activeIndex + 1) % optionCount);
|
|
350
|
+
break;
|
|
351
|
+
|
|
352
|
+
case 'ArrowUp':
|
|
353
|
+
event.preventDefault();
|
|
354
|
+
if (optionCount > 0) this.setActive((this.activeIndex - 1 + optionCount) % optionCount);
|
|
355
|
+
break;
|
|
356
|
+
|
|
302
357
|
case 'Tab':
|
|
303
358
|
event.preventDefault();
|
|
304
|
-
if (this.activeIndex < 0) this.setActive(0);
|
|
359
|
+
if (this.activeIndex < 0) this.setActive(0);
|
|
305
360
|
else this.selectValue(this.selectors[this.activeIndex]); // Otherwise select what is active.
|
|
306
361
|
break;
|
|
307
362
|
}
|
|
308
363
|
};
|
|
309
364
|
newtag.oninput = () => this.renderSelector(newtag.value);
|
|
310
|
-
|
|
365
|
+
// When we click on the listbox, the browser will first blur newtag, and then
|
|
366
|
+
// we would not get the click! So here we delay closing a bit.
|
|
367
|
+
newtag.onblur = () => setTimeout(() => this.closeSelector(), 200);
|
|
311
368
|
newtag.onchange = () => this.acceptTag();
|
|
312
369
|
},
|
|
313
370
|
remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
|
|
@@ -316,20 +373,31 @@ export const Hashtags = {
|
|
|
316
373
|
this.onchange({redisplaySubscribers, resetSubscriptions: false});
|
|
317
374
|
},
|
|
318
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
|
|
319
379
|
const label = chip.label;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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;
|
|
329
399
|
}
|
|
330
|
-
|
|
331
|
-
else if (isPub && !chip.selected) { chip.selected = true; return; } // Don't allow deselecting the only pub tag.
|
|
332
|
-
this.hashtags[label] = chip.selected;
|
|
400
|
+
return this.hashtags[label] = false;
|
|
333
401
|
},
|
|
334
402
|
getChip(label) { // Handy for scripting, but not otherwise used in app.
|
|
335
403
|
for (const chip of this.chipset.children) {
|
|
@@ -338,36 +406,24 @@ export const Hashtags = {
|
|
|
338
406
|
return null;
|
|
339
407
|
},
|
|
340
408
|
setPublish(newTag) { // Make this topic be the one to be used when we next publish an alert.
|
|
341
|
-
|
|
342
|
-
|
|
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;
|
|
343
416
|
this.hashtags[newTag] = 'pub';
|
|
344
417
|
for (const chip of this.chipset.children) {
|
|
345
|
-
if (chip.label ===
|
|
346
|
-
else if (chip.label ===
|
|
418
|
+
if (chip.label === newTag) chip.classList.add('pub');
|
|
419
|
+
else if (chip.label === oldTag) chip.classList.remove('pub');
|
|
347
420
|
}
|
|
348
421
|
return oldTag;
|
|
349
422
|
}
|
|
350
423
|
};
|
|
424
|
+
globalThis.Hashtags = Hashtags; // for debugging
|
|
351
425
|
|
|
352
426
|
// Populate hashtags data and display.
|
|
353
427
|
// First the persisted/default data:
|
|
354
|
-
const
|
|
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
|
-
const persisted = JSON.parse(localStorage.getItem('hashtags') || `{"🍰 ${Int`cake`}": true, "🆘 ${Int`help`}": "pub"}`);
|
|
428
|
+
const persisted = JSON.parse(localStorage.getItem('hashtags') || `{"🍰 ${Int`cake`}": true, "${help}": "pub"}`);
|
|
373
429
|
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);
|
|
@@ -233,17 +237,17 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
|
|
|
233
237
|
if (level9Cell) { // Zoomed out near where we last where, but not too exact for security.
|
|
234
238
|
zoom = 12;
|
|
235
239
|
[lat, lng] = getPointInCell(BigInt(level9Cell));
|
|
236
|
-
} else {
|
|
240
|
+
} else { // If the user doesn't want to turn on geolocation, we whould certainly not use ipinfo.io or the like.
|
|
237
241
|
zoom = 13;
|
|
238
242
|
[lat, lng] = [37.7749, -122.4194]; // San Fransisco
|
|
239
243
|
}
|
|
240
244
|
}
|
|
241
|
-
console.log('initializeGeolocation updateLocation');
|
|
245
|
+
//console.log('initializeGeolocation updateLocation');
|
|
242
246
|
updateLocation(lat, lng, zoom, positionLabel);
|
|
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');
|
|
@@ -254,7 +258,7 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
|
|
|
254
258
|
positionWatch = geolocation.watchPosition(
|
|
255
259
|
position => {
|
|
256
260
|
const {latitude, longitude} = position.coords;
|
|
257
|
-
console.log('Location update.', map ? 'Map exists.' : 'Will create map.', subscribeOneShot ? 'Will subscribe fresh.' : 'Has subscriptions.');
|
|
261
|
+
//console.log('Location update.', map ? 'Map exists.' : 'Will create map.', subscribeOneShot ? 'Will subscribe fresh.' : 'Has subscriptions.');
|
|
258
262
|
initMap(latitude, longitude);
|
|
259
263
|
}, error => {
|
|
260
264
|
geolocation.clearWatch(positionWatch);
|
|
@@ -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;
|
|
@@ -153,7 +153,7 @@ export function initMap(lat, lng, zoom, positionLabel) { // Set up appropriate z
|
|
|
153
153
|
if (document.getElementById('map').querySelector('.leaflet-popup')) return; // Ignore clicks with popup open.
|
|
154
154
|
const { lat, lng } = e.latlng;
|
|
155
155
|
Alert.openPopup(await Alert.publish({lat, lng}));
|
|
156
|
-
Agent.current.persistPublicMetadata(
|
|
156
|
+
Agent.current.persistPublicMetadata();
|
|
157
157
|
});
|
|
158
158
|
if (document.querySelector('.leaflet-control-zoom')) { // Not present in mobile
|
|
159
159
|
tooltip('.leaflet-control-zoom-in', Int`Zoom in to show more detail in the map.`);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
-
import {
|
|
3
|
-
import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from '
|
|
4
|
-
|
|
5
|
-
globalThis.RTCPeerConnection ||= await import('node-datachannel/polyfill').then(ndc => ndc.RTCPeerConnection); // To support @axona/protocol < 4.26
|
|
2
|
+
import { connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from './protocol.js';
|
|
3
|
+
import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from './protocol.js';
|
|
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'); },
|
|
@@ -29,20 +28,24 @@ export class P2PWebNetwork {
|
|
|
29
28
|
region = this.sessionRegion,
|
|
30
29
|
bridgeUrl = (globalThis.location && new URL(globalThis.location).searchParams.get('bridge')) ||
|
|
31
30
|
globalThis.process?.env.BRIDGE_URL ||
|
|
31
|
+
(parseInt(new URL(globalThis.location || 'file://').searchParams.get('dht')) <= 0 && // fixme remove when we host bridges
|
|
32
|
+
globalThis.location?.origin.replace(/^http/, 'ws')) ||
|
|
32
33
|
'wss://bridge.axona.net',
|
|
33
34
|
} = {}) {
|
|
34
35
|
// Promise a ready-to-use network peer.
|
|
35
36
|
region = await region;
|
|
36
37
|
|
|
38
|
+
const network = new this();
|
|
39
|
+
network.resetStatePromises();
|
|
40
|
+
|
|
37
41
|
const { peer, nodeIdentity, transport, status, disconnect } = await connect({
|
|
38
42
|
bridge: bridgeUrl,
|
|
39
43
|
location: region,
|
|
44
|
+
onDisconnect: network.detached,
|
|
40
45
|
author: false
|
|
41
46
|
});
|
|
42
|
-
|
|
43
|
-
const network = new this();
|
|
44
47
|
Object.assign(network, {infoLogger, debugLogger, disconnector: disconnect, transport, nodeIdentity, peer});
|
|
45
|
-
|
|
48
|
+
|
|
46
49
|
network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(region.lat, region.lng).toString(16)}.`);
|
|
47
50
|
peer.onError(error => {
|
|
48
51
|
network.info(`error: ${error.message || error}`);
|
|
@@ -50,7 +53,7 @@ export class P2PWebNetwork {
|
|
|
50
53
|
});
|
|
51
54
|
//peer.onLog('debug', (...rest) => network.debug('DEBUG', ...rest));
|
|
52
55
|
//peer.onLog('info', (...rest) => network.debug('INFO', ...rest));
|
|
53
|
-
peer.onLog('warn', (...rest) => network.info('WARNING', ...rest));
|
|
56
|
+
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
57
|
peer.onLog('error', (...rest) => network.info('ERROR', ...rest));
|
|
55
58
|
const { peers, ms } = status;
|
|
56
59
|
network.info(`Connected ${peers} connections through ${bridgeUrl} in ${ms.toLocaleString()} ms.`);
|
|
@@ -58,10 +61,11 @@ export class P2PWebNetwork {
|
|
|
58
61
|
return network;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
async disconnect() { // Politely close network connection.
|
|
64
|
+
async disconnect(debugLogger = this.debugLogger) { // Politely close network connection.
|
|
62
65
|
const health = this.peer.health();
|
|
63
66
|
await this.disconnector();
|
|
64
|
-
|
|
67
|
+
if (debugLogger) debugLogger(health);
|
|
68
|
+
else this.shortHealth(health);
|
|
65
69
|
this.resetStatePromises();
|
|
66
70
|
}
|
|
67
71
|
async replicateStorage() { // Let the network know that we might go away without further notice.
|
|
@@ -99,7 +103,8 @@ export class P2PWebNetwork {
|
|
|
99
103
|
img.src = URL.createObjectURL(file);
|
|
100
104
|
});
|
|
101
105
|
}
|
|
102
|
-
static async downsampledBlob({blob,
|
|
106
|
+
static async downsampledBlob({blob, maxDimension = 1024, inputType = blob.type,
|
|
107
|
+
outputType = inputType.startsWith('image/') ? 'image/jpeg' : inputType}) { // ONLY IN BROWSERS!
|
|
103
108
|
// Promise a reasonably sized Blob (or File) for a given Blob of type image/*, else blob unchanged.
|
|
104
109
|
if (!blob.type.startsWith('image/')) return blob;
|
|
105
110
|
|
|
@@ -175,6 +180,7 @@ export class P2PWebNetwork {
|
|
|
175
180
|
await this.attachment;
|
|
176
181
|
const topic = {region, name: eventName};
|
|
177
182
|
if (owner) topic.owner = owner;
|
|
183
|
+
this.debug('subscribed', {topic, handler:!!handler});
|
|
178
184
|
if (handler) {
|
|
179
185
|
const callback = async envelope => {
|
|
180
186
|
const {message, deleted, msgId, signerPubkey, topic, ts} = envelope;
|
|
@@ -187,18 +193,18 @@ export class P2PWebNetwork {
|
|
|
187
193
|
};
|
|
188
194
|
await this.peer.sub(topic, callback, {since});
|
|
189
195
|
} else {
|
|
190
|
-
this.peer.unsub(topic, {});
|
|
196
|
+
await this.peer.unsub(topic, {});
|
|
191
197
|
}
|
|
192
198
|
}
|
|
193
199
|
static currentPublishIdentity = null;
|
|
194
|
-
async publish({eventName, region, owner, signWith = this.constructor.currentPublishIdentity, issuedTime = Date.now(), killTag, payload, ...rest}) {
|
|
200
|
+
async publish({eventName, region, owner, signWith = this.constructor.currentPublishIdentity, issuedTime = Date.now(), subject, killTag = subject, payload, ...rest}) {
|
|
195
201
|
// Publish data to subscribers of eventName.
|
|
196
202
|
if (killTag && payload) throw new Error(`Specify killTag (${killTag}) or payload ($(JSON.stringify(payload)}), but not both.`);
|
|
197
203
|
await this.attachment; // Get connected.
|
|
198
204
|
const topic = {region, name: eventName};
|
|
199
205
|
if (owner) topic.owner = owner;
|
|
200
206
|
const options = {signWith};
|
|
201
|
-
this.debug('published', {topic, killTag, payload, issuedTime, rest, signWith});
|
|
207
|
+
this.debug('published', {topic, killTag, payload, issuedTime, rest, signWith:signWith.authorId});
|
|
202
208
|
if (payload) return await this.peer.pub(topic, {issuedTime, payload, ...rest}, options);
|
|
203
209
|
// 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
210
|
if (!killTag) return await this.peer.pub(topic, {issuedTime, payload, ...rest}, options);
|
|
@@ -215,7 +221,7 @@ export class P2PWebNetwork {
|
|
|
215
221
|
static regionCode(lat, lng) { // Answer containing region code.
|
|
216
222
|
return geoCellId(lat, lng);
|
|
217
223
|
}
|
|
218
|
-
static regionCenter(regionCode) {
|
|
224
|
+
static regionCenter(regionCode) { // {lat, lng} of center of regionCode
|
|
219
225
|
return geoCellCenter(regionCode);
|
|
220
226
|
}
|
|
221
227
|
static delay(ms, result) { // Promise result after ms milliseconds.
|
|
@@ -233,12 +239,31 @@ export class P2PWebNetwork {
|
|
|
233
239
|
// E.g., a precise location gets anonymized to containing top-level cell center.
|
|
234
240
|
return this.regionCenter(this.regionCode(lat, lng));
|
|
235
241
|
}
|
|
236
|
-
// Todo: Integrate with AxonaPeer's complex logging.
|
|
237
242
|
debug(...rest) { // Add debug logspam.
|
|
238
243
|
this.debugLogger?.(this.nodeIdentity.id, ...rest);
|
|
239
244
|
}
|
|
240
245
|
info(...rest) { // Add debug logspam.
|
|
241
246
|
(this.infoLogger || this.debugLogger)?.(this.nodeIdentity.id, ...rest);
|
|
242
247
|
}
|
|
248
|
+
static short(tag) {
|
|
249
|
+
return tag.slice(0, 8);
|
|
250
|
+
}
|
|
251
|
+
shortHealth(health = this.peer.health()) { // Info-report a short form of health.
|
|
252
|
+
const roots = health.axonRoles.filter(r => r.isRoot);
|
|
253
|
+
this.info(`disconnected with connections ${health.peers.map(P2PWebNetwork.short)}\nand ${roots.length ? `roots ${roots.map(r => P2PWebNetwork.short(r.topic))}.` : 'no roots.'}`);
|
|
254
|
+
}
|
|
255
|
+
inRegionConnections(health = this.peer.health()) {
|
|
256
|
+
const region = this.nodeIdentity.id.slice(0, 2);
|
|
257
|
+
return health.peers.filter(tag => tag.startsWith(region));
|
|
258
|
+
}
|
|
259
|
+
ice(tags = this.peer.health().peers) {
|
|
260
|
+
const ice = {};
|
|
261
|
+
for (const nodeTag of tags) {
|
|
262
|
+
const meshTag = this.transport.webrtc.meshIdFor(BigInt('0x'+nodeTag));
|
|
263
|
+
const peer = this.transport.mesh._peers.get(meshTag);
|
|
264
|
+
ice[P2PWebNetwork.short(nodeTag)] = peer ? `${peer.localCand} => ${peer.remoteCand}` : 'no webrtc connection';
|
|
265
|
+
}
|
|
266
|
+
return ice;
|
|
267
|
+
}
|
|
243
268
|
}
|
|
244
269
|
export default P2PWebNetwork;
|