@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.
@@ -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 oursHasEmoji = this.firstEmoji(ourExtended);
39
- const extended = oursHasEmoji ? ourExtended : label; // full emoji form to use
40
- if (!oursHasEmoji) {
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 (!oursHasEmoji) {
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
- return this.getAll().find(key => this.isPublish(key));
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(tag) + tag;
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
- tags.sort((a, b) => stripLeadingEmoji(a).localeCompare(stripLeadingEmoji(b)));
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
- `<md-filled-text-field class="newtag" placeholder="➕${Int`add topic`}"></md-filled-text-field>`);
143
- clickTip(this.chipset.firstChild, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
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
- showMessage(Int`Type a new topic name to see any alerts on the map with this topic.`, 'instructions');
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
- this.chipset.firstChild.onchange = event => { // Add the new hashtag.
149
- resetInactivityTimer();
150
- let tag = event.target.value.trim() // Get into standard form, but do not strip emoji or case into canonical yet.
151
- .replace(/^#/, '') // No leading hash
152
- .replace(/\s+/g, ' ') // Replace multiple spaces with a single space
153
- .normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
154
- if (!tag) return;
155
- Alert.closePopup();
156
- tag = this.add(tag); // Might exist, in which case tag might now be extended.
157
- this.setPublish(tag);
158
- this.onchange({highlightPublish: true});
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
- const isPub = label === this.getPublish();
169
- let altPub;
170
- if (isPub) { // Find the next alternative, if possible.
171
- let subs = this.getSubscribe();
172
- if (subs.length > 1) {
173
- let pubIndex = subs.indexOf(label);
174
- let index = (pubIndex + 1) % subs.length;
175
- altPub = subs[index];
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
- if (altPub) this.setPublish(altPub);
179
- else if (isPub && !chip.selected) { chip.selected = true; return; } // Don't allow deselecting the only pub tag.
180
- this.hashtags[label] = chip.selected;
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] = true;
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([]); // This was for a new node, so supply an empty oldSubscriptions.
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
- document.getElementById('aboutContainer').classList.toggle('hidden', true);
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
- tooltip('.leaflet-control-zoom-in', Int`Zoom in to show more detail in the map.`);
159
- tooltip('.leaflet-control-zoom-out', Int`Zoom out to show a larger area in the map.`);
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
- import { connect } from '@axona/protocol/connect.js';
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
- this.info(`disconnected with ${health.peers.length} connections and ${health.axonRoles.length} axons.`);
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, outputType = 'image/jpeg', maxDimension = 1024}) { // ONLY IN BROWSERS!
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;
@@ -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 = "Installing..."; // In case there is some delay, tell the user what we're trying to do. Will be cleared with reload.
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 = new URL(location.href);
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
- // 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, '') || string;
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
  }