@yz-social/civildefense.io 4.5.9 → 4.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,26 @@ import { showMessage } from './map.js';
5
5
  import { Alert } from './alert.js';
6
6
  import { resetInactivityTimer, clickTip } from './main.js';
7
7
 
8
+
9
+ let allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
10
+ "🍰 cake",
11
+ "🎸 classic rock",
12
+ "🎼 classical",
13
+ "🩷 community support",
14
+ "🤠 country",
15
+ "🎧 edm",
16
+ "🔥 fire",
17
+ "🌊 flood",
18
+ "🆘 help",
19
+ "🎤 hiphop",
20
+ "🧊 ice",
21
+ "🎷 jazz",
22
+ "🎙️ news",
23
+ "👁️ observer corps",
24
+ "🎵 pop",
25
+ "🧰 utility repairs"
26
+ ]`);
27
+
8
28
  // We subscribe to the cartesian product of the list of non-overlapping cells and all hashes.
9
29
  // We publish to just the first of these.
10
30
  export const Hashtags = {
@@ -25,8 +45,8 @@ export const Hashtags = {
25
45
  // Later, we will allow a user to change what they see on their own device.)
26
46
  // 4. While an identicon is displayed, it is the same for all markers of this hashtag, regardless of whether the original posters
27
47
  // were using the same emoji as each other.
28
- hashtags: {},
29
- canonical2extended: {},
48
+ hashtags: {}, // extended string => true/false/'pub'
49
+ canonical2extended: {}, // canonical string => extended string
30
50
  add(label, active = true, updateAlerts = true) { // Ensure label is a hashtag, initialized to active, and if existing, forcing it active.
31
51
  // Return our (possibly new) understanding of the extended hashtag.
32
52
  // Note that only startup-population of tags from persistence would ever specify active=false.
@@ -35,15 +55,16 @@ export const Hashtags = {
35
55
  // (We do not change the emoji of an existing extended.)
36
56
  const canonical = canonicalTag(label); // no emoji, lower case.
37
57
  const ourExtended = this.canonical2extended[canonical]; // our current version, if any
38
- const 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
  }
@@ -64,7 +85,13 @@ export const Hashtags = {
64
85
  return this.hashtags[key] === 'pub';
65
86
  },
66
87
  getPublish() { // Return the one hashtag to which the user intends to publish.
67
- 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;
68
95
  },
69
96
  firstEmoji(tag) { // First emoji that appears in string, else falsy.
70
97
  // I would prefer that it take just the first emoji, but that doesn't grab double-wide ones
@@ -102,6 +129,19 @@ export const Hashtags = {
102
129
  <md-icon-button slot="remove-trailing-icon" title="fixme help"><md-icon class="material-icons"></md-icon></md-icon-button>
103
130
  </md-filter-chip>`;
104
131
  },
132
+
133
+ // Topic entry, with autocomplete.
134
+ // - When you click the input box, it shows all the topics we know about.
135
+ // This includes topics you already have available, because you might not realize you have them.
136
+ // - As you type, it filters out entries that do not match:
137
+ // - If you happen to start with an emoji and have not yet entered a separating space, it matches against those that use the same emoji.
138
+ // - Otherwise, it matches text, ignoring the emoji, and it highlights the substring that matches.
139
+ // - up/down arrow or tab ==> highlights from among those shown and copies its text to the input box, but not yet accepting it.
140
+ // - But if tab is used when something was already highlighted, then it is accepted (like enter).
141
+ // - click (regardless of highlighting) ==> what you click on is used.
142
+ // - enter:
143
+ // - something highlighted ==> what is highlighted is used.
144
+ // - otherwise => exact contents of input box is used.
105
145
  closeSelector() { // Close the autocomplete tag selector
106
146
  const { listbox, newtag } = this;
107
147
  listbox.classList.toggle('hidden', true);
@@ -115,7 +155,7 @@ export const Hashtags = {
115
155
  listbox.classList.toggle('hidden', false);
116
156
  newtag?.setAttribute('aria-expanded', 'true');
117
157
  },
118
- setActive(index) {
158
+ setActive(index) { // Highlight the index among selectors, and copy it's value to newtag.
119
159
  const { listbox, newtag } = this;
120
160
  this.activeIndex = index;
121
161
  listbox.querySelectorAll('.combobox-option').forEach((option, optionIndex) => {
@@ -128,19 +168,28 @@ export const Hashtags = {
128
168
  newtag.removeAttribute('aria-activedescendant');
129
169
  }
130
170
  });
171
+ newtag.value = this.selectors[this.activeIndex];
131
172
  },
132
- selectValue(value) {
173
+ selectValue(value) { // Accept the specified string.
133
174
  this.newtag.value = value;
134
175
  this.acceptTag();
135
176
  },
136
177
  acceptTag() { // Add the new hashtag.
137
178
  resetInactivityTimer();
138
- let tag = this.newtag?.value.trim() // Get into standard form, but do not strip emoji or case into canonical yet.
179
+ let tag = this.newtag?.value // Get into standard form, but do not strip emoji or case into canonical yet.
139
180
  .replace(/^#/, '') // No leading hash
140
181
  .replace(/\s+/g, ' ') // Replace multiple spaces with a single space
141
182
  .normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
142
183
  Alert.closePopup();
143
184
  if (!tag) return;
185
+ if (this.firstEmoji(tag)) { // Possibly REPLACE existing with the new tag.
186
+ const canonical = canonicalTag(tag);
187
+ const existingExtended = this.canonical2extended[canonical];
188
+ if (existingExtended !== tag) {
189
+ delete this.canonical2extended[canonical];
190
+ delete this.hashtags[existingExtended];
191
+ }
192
+ }
144
193
  tag = this.add(tag); // Might exist, in which case tag might now be extended.
145
194
  this.setPublish(tag);
146
195
  this.onchange({highlightPublish: true});
@@ -160,13 +209,17 @@ export const Hashtags = {
160
209
 
161
210
  // TODO: create the elements only when allKnownHashtags changes. Then hide/highlight here.
162
211
  listbox.innerHTML = '';
212
+ let matchString = canonicalTag(query);
163
213
  this.selectors = allKnownHashtags.filter(item =>
164
- item.toLowerCase().includes(query.toLowerCase())
214
+ // Subtle: Starting with emoji will match all/only those tags that start with the same tag. Cool.
215
+ // But because of the way canonicalTag() works, once you type a space after an emoji, we match
216
+ // against only the non-emoji, non-space text.
217
+ item.toLowerCase().includes(matchString)
165
218
  );
166
219
 
167
- if (this.selectors.length === 0) {
220
+ if (matchString) {
168
221
  const li = document.createElement('li');
169
- li.className = 'combobox-empty';
222
+ li.className = 'combobox-empty combobox-option';
170
223
  li.textContent = `Create tag "${query}"`;
171
224
  li.onpointerdown = event => {
172
225
  event.preventDefault();
@@ -174,34 +227,28 @@ export const Hashtags = {
174
227
  this.acceptTag();
175
228
  };
176
229
  listbox.appendChild(li);
177
- } else {
178
- this.selectors.forEach((item, i) => {
179
- const li = document.createElement('li');
180
- li.className = 'combobox-option';
181
- li.id = `tag-option-${i}`;
182
- li.setAttribute('role', 'option');
183
- li.innerHTML = this.formatPubtag(highlight(item, query), item);
184
- li.onpointerdown = event => {
185
- // pointerdown (not click) so it fires before the field's blur event
186
- event.preventDefault();
187
- event.stopPropagation();
188
- this.selectValue(item);
189
- };
190
- listbox.appendChild(li);
191
- });
192
- setTimeout(() => { // Needs a tick.
193
- const width = listbox.clientWidth;
194
- const max = Math.max(125, width);
195
- newtag.style.width = max + 'px'; // Set newtag so that input box makes room for floating lis
196
- }, 50);
197
230
  }
231
+ this.selectors.forEach((item, i) => {
232
+ const li = document.createElement('li');
233
+ li.className = 'combobox-option';
234
+ li.id = `tag-option-${i}`;
235
+ li.setAttribute('role', 'option');
236
+ li.innerHTML = this.formatPubtag(highlight(item, matchString), item);
237
+ li.onpointerdown = event => {
238
+ // pointerdown (not click) so it fires before the field's blur event
239
+ event.preventDefault();
240
+ event.stopPropagation();
241
+ this.selectValue(item);
242
+ };
243
+ listbox.appendChild(li);
244
+ });
245
+ setTimeout(() => { // Needs a tick.
246
+ const width = listbox.clientWidth;
247
+ const max = Math.max(125, width);
248
+ newtag.style.width = max + 'px'; // Set newtag so that input box makes room for floating lis
249
+ }, 50);
198
250
 
199
251
  this.openSelector();
200
- if (this.selectors.length === 1) {
201
- this.setActive(0);
202
- } else {
203
- this.setActive(-1);
204
- }
205
252
  },
206
253
  sort(tags) { // Sort list of tags in place without regard to leding emoji
207
254
  tags.sort((a, b) => stripLeadingEmoji(a).localeCompare(stripLeadingEmoji(b)));
@@ -243,7 +290,6 @@ export const Hashtags = {
243
290
  else showMessage(Int`Turning off "${chip.label}" alerts in the map. You can delete the topic altogether with the X.`, 'instructions');
244
291
  this.toggleChip(chip);
245
292
  Alert.closePopup();
246
- if (chip.selected) this.setPublish(chip.label);
247
293
  this.onchange({redisplaySubscribers: false});
248
294
  });
249
295
  });
@@ -275,20 +321,10 @@ export const Hashtags = {
275
321
  const optionCount = listbox.querySelectorAll('.combobox-option').length;
276
322
 
277
323
  switch (event.key) {
278
- case 'ArrowDown':
279
- event.preventDefault();
280
- if (optionCount > 0) this.setActive((this.activeIndex + 1) % optionCount);
281
- break;
282
-
283
- case 'ArrowUp':
284
- event.preventDefault();
285
- if (optionCount > 0) this.setActive((this.activeIndex - 1 + optionCount) % optionCount);
286
- break;
287
-
288
324
  case 'Enter':
289
325
  event.preventDefault();
290
326
  if (this.activeIndex < 0) {
291
- this.acceptTag();
327
+ this.acceptTag(); // As is, not from list.
292
328
  } else {
293
329
  this.selectValue(this.selectors[this.activeIndex]);
294
330
  }
@@ -299,16 +335,26 @@ export const Hashtags = {
299
335
  this.closeSelector();
300
336
  break;
301
337
 
338
+ case 'ArrowDown':
339
+ event.preventDefault();
340
+ if (optionCount > 0) this.setActive((this.activeIndex + 1) % optionCount);
341
+ break;
342
+
343
+ case 'ArrowUp':
344
+ event.preventDefault();
345
+ if (optionCount > 0) this.setActive((this.activeIndex - 1 + optionCount) % optionCount);
346
+ break;
347
+
302
348
  case 'Tab':
303
349
  event.preventDefault();
304
- if (this.activeIndex < 0) this.setActive(0); // If nothing active, make the first line active.
350
+ if (this.activeIndex < 0) this.setActive(0);
305
351
  else this.selectValue(this.selectors[this.activeIndex]); // Otherwise select what is active.
306
352
  break;
307
353
  }
308
354
  };
309
355
  newtag.oninput = () => this.renderSelector(newtag.value);
310
356
  newtag.onblur = () => this.closeSelector();
311
- newtag.onchange = () => this.acceptTag();
357
+ //newtag.onchange = () => this.acceptTag();
312
358
  },
313
359
  remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
314
360
  delete this.hashtags[chip.label];
@@ -316,20 +362,28 @@ export const Hashtags = {
316
362
  this.onchange({redisplaySubscribers, resetSubscriptions: false});
317
363
  },
318
364
  toggleChip(chip) { // Switch whether the topic is or is not subscribed.
365
+ // Now selected => hashtags[label] becomes 'pub' (selected and the publisher) and clear old pub
366
+ // NOT now selected => hashtags[label] becomes false (through mechanism as follows)
367
+ // but if publisher => set alt publisher if possible, else remember as lastActivePublisher
319
368
  const label = chip.label;
320
- const isPub = label === this.getPublish();
321
- let altPub;
322
- if (isPub) { // Find the next alternative, if possible.
323
- let subs = this.getSubscribe();
324
- if (subs.length > 1) {
325
- let pubIndex = subs.indexOf(label);
326
- let index = (pubIndex + 1) % subs.length;
327
- altPub = subs[index];
328
- }
369
+
370
+ // chip.selected is new state, after clicking.
371
+ if (chip.selected) return this.setPublish(label); // Become publisher, clearing old publisher.
372
+
373
+ // If we're not publisher, just clear. But don't go through getPublish, as that can have side effects.
374
+ if (this.hashtags[label] !== 'pub') return this.hashtags[label] = false;
375
+
376
+ // Find an alternative publisher if possible.
377
+ let subs = this.getSubscribe();
378
+ if (subs.length > 1) {
379
+ let pubIndex = subs.indexOf(label);
380
+ let index = (pubIndex + 1) % subs.length;
381
+ return this.setPublish(subs[index], false);
329
382
  }
330
- if (altPub) this.setPublish(altPub);
331
- else if (isPub && !chip.selected) { chip.selected = true; return; } // Don't allow deselecting the only pub tag.
332
- 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;
333
387
  },
334
388
  getChip(label) { // Handy for scripting, but not otherwise used in app.
335
389
  for (const chip of this.chipset.children) {
@@ -337,9 +391,9 @@ export const Hashtags = {
337
391
  }
338
392
  return null;
339
393
  },
340
- setPublish(newTag) { // Make this topic be the one to be used when we next publish an alert.
394
+ setPublish(newTag, isOldTagNowSubscribed = true) { // Make this topic be the one to be used when we next publish an alert.
341
395
  const oldTag = this.getPublish();
342
- this.hashtags[oldTag] = true;
396
+ if (oldTag) this.hashtags[oldTag] = isOldTagNowSubscribed;
343
397
  this.hashtags[newTag] = 'pub';
344
398
  for (const chip of this.chipset.children) {
345
399
  if (chip.label === oldTag) chip.classList.remove('pub');
@@ -348,26 +402,9 @@ export const Hashtags = {
348
402
  return oldTag;
349
403
  }
350
404
  };
405
+ globalThis.Hashtags = Hashtags; // for debugging
351
406
 
352
407
  // Populate hashtags data and display.
353
408
  // First the persisted/default data:
354
- const allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
355
- "🍰 cake",
356
- "🎸 classic rock",
357
- "🎼 classical",
358
- "🩷 community support",
359
- "🤠 country",
360
- "🎧 edm",
361
- "🔥 fire",
362
- "🌊 flood",
363
- "🆘 help",
364
- "🎤 hiphop",
365
- "🧊 ice",
366
- "🎷 jazz",
367
- "🎙️ news",
368
- "👁️ observer corps",
369
- "🎵 pop",
370
- "🧰 utility repairs"
371
- ]`);
372
409
  const persisted = JSON.parse(localStorage.getItem('hashtags') || `{"🍰 ${Int`cake`}": true, "🆘 ${Int`help`}": "pub"}`);
373
410
  Object.entries(persisted).forEach(([tag, active]) => Hashtags.add(tag, active, false));
@@ -8,6 +8,7 @@ import { getPointInCell } from './s2.js';
8
8
  import { Alert, getShareableURL, share } from './alert.js';
9
9
  import { map, showMessage, updateLocation, recenterMap } from './map.js';
10
10
  import './service-manager.js'; // Comment this out and kill service-workers for reload-to-get-latest behavior during development.
11
+ window.P2PWebNetwork = P2PWebNetwork;
11
12
 
12
13
  document.getElementById('appVersion').textContent = appVersion;
13
14
  document.getElementById('kernelVersion').textContent = P2PWebNetwork.kernelVersion;
@@ -123,6 +124,9 @@ export function openAbout(event) {
123
124
  openDisplay('aboutContainer', event);
124
125
  noteNotificationPermission(window.Notification?.permission);
125
126
  }
127
+ export function closeAbout() {
128
+ document.getElementById('aboutContainer').classList.toggle('hidden', true);
129
+ }
126
130
  clickTip('#aboutButton', Int`Information about this app, and options to change notifications or how you appear to others.`, event => { // open about
127
131
  Alert.closePopup();
128
132
  openAbout(event);
@@ -243,7 +247,7 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
243
247
  if (!subscribeOneShot) return;
244
248
  subscribeOneShot = false;
245
249
  resetInactivityTimer(false);
246
- Alert.updateSubscriptions([]); // 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;
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  const { Request, Response, URL, clients} = self;
2
2
  // Little point in trying to automatically pull this through package.json, as we need a byte change in THIS file to trigger a new worker.
3
- const serviceVersion = '4.5.9';
3
+ const serviceVersion = '4.5.16';
4
4
 
5
5
  const cacheList = [ // The files we need.
6
6
  "/",
@@ -172,13 +172,13 @@ async function cacheSource(version, event) { // Cache source in the given versio
172
172
  await Promise.all([
173
173
  // These are referenced within material web, but missing. Turns out we don't need them,
174
174
  // but let's cache empty responses to keep the console cleaner.
175
- "https://esm.run/npm/lit@3.3.1/+esm",
175
+ "https://esm.run/npm/lit@3.3.3/+esm",
176
176
  "https://esm.run/npm/tslib@2.8.1/+esm",
177
- "https://esm.run/npm/lit@3.3.1/static-html.js/+esm",
178
- "https://esm.run/npm/lit@3.3.1/decorators.js/+esm",
179
- "https://esm.run/npm/lit@3.3.1/directives/style-map.js/+esm",
180
- "https://esm.run/npm/lit@3.3.1/directives/class-map.js/+esm",
181
- "https://esm.run/npm/lit@3.3.1/directives/when.js/+esm",
177
+ "https://esm.run/npm/lit@3.3.3/static-html.js/+esm",
178
+ "https://esm.run/npm/lit@3.3.3/decorators.js/+esm",
179
+ "https://esm.run/npm/lit@3.3.3/directives/style-map.js/+esm",
180
+ "https://esm.run/npm/lit@3.3.3/directives/class-map.js/+esm",
181
+ "https://esm.run/npm/lit@3.3.3/directives/when.js/+esm",
182
182
  "https://esm.run/npm/lit@3.3.1/directives/live.js/+esm",
183
183
  ].map(url => cache.put(new Request(url),
184
184
  new Response("", {headers: { "Content-Type": "text/javascript" }}))));
@@ -323,6 +323,7 @@ md-filter-chip minidenticon-svg {
323
323
  md-outlined-icon-button minidenticon-svg, md-outlined-icon-button img {
324
324
  height: 40px;
325
325
  width: 40px;
326
+ object-fit: cover;
326
327
  border-radius: 12px;
327
328
  }
328
329
  .alert span minidenticon-svg {
@@ -388,9 +389,9 @@ md-menu {
388
389
  md-outlined-button {
389
390
  padding: 10px;
390
391
  }
391
- .attribution > div:last-child > span {
392
- text-wrap: nowrap;
393
- }
392
+ /* .attribution > div:last-child > span { */
393
+ /* text-wrap: nowrap; */
394
+ /* } */
394
395
  md-menu-item minidenticon-svg {
395
396
  display: inline-block;
396
397
  height: 20px;
@@ -424,7 +425,7 @@ md-menu-item minidenticon-svg {
424
425
  .attribution-metadata {
425
426
  display: flex;
426
427
  flex-direction: column;
427
- align-items: end;
428
+ /* align-items: end; */
428
429
  }
429
430
  .alert {
430
431
  font-family: roboto;