@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.
@@ -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 oursHasEmoji = this.firstEmoji(ourExtended);
39
- const extended = oursHasEmoji ? ourExtended : label; // full emoji form to use
40
- if (!oursHasEmoji) {
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 (!oursHasEmoji) {
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
- getPublish() { // Return the one hashtag to which the user intends to publish.
67
- return this.getAll().find(key => this.isPublish(key));
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
- Alert.updateSubscriptions();
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" title="fixme help"><md-icon class="material-icons"></md-icon></md-icon-button>
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.trim() // Get into standard form, but do not strip emoji or case into canonical yet.
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
- item.toLowerCase().includes(query.toLowerCase())
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 (this.selectors.length === 0) {
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 = this.chipset.querySelector('.combobox-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); // If nothing active, make the first line active.
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
- newtag.onblur = () => this.closeSelector();
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
- 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
- }
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
- 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;
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
- const oldTag = this.getPublish();
342
- this.hashtags[oldTag] = true;
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 === oldTag) chip.classList.remove('pub');
346
- else if (chip.label === newTag) chip.classList.add('pub');
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 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
- 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([]); // 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');
@@ -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
- 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;
@@ -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(P2PWebNetwork.regionCode(lat, lng));
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 { createNodeIdentity, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from '@axona/protocol';
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
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
- network.resetStatePromises();
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
- this.info(`disconnected with ${health.peers.length} connections and ${health.axonRoles.length} axons.`);
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, outputType = 'image/jpeg', maxDimension = 1024}) { // ONLY IN BROWSERS!
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;