@yz-social/civildefense.io 4.5.16 → 4.5.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yz-social/civildefense.io",
3
3
  "description": "Browser app to safely share live sightings on a map.",
4
- "version": "4.5.16",
4
+ "version": "4.5.22",
5
5
  "keywords": [
6
6
  "map",
7
7
  "browser",
@@ -29,6 +29,7 @@
29
29
  "pica": "^9.0.1",
30
30
  "s2js": "^1.44.0",
31
31
  "uuid": "^14.0.0",
32
+ "ws": "^8.21.3",
32
33
  "yargs": "^18.0.0"
33
34
  },
34
35
  "overrides": {
package/public/index.html CHANGED
@@ -160,6 +160,7 @@
160
160
  </div>
161
161
 
162
162
  <md-menu positioning="popover" id="popoverMenu"></md-menu>
163
+ <ul class="combobox-listbox hidden" id="knownTagsListbox" role="listbox"></ul>
163
164
  </section>
164
165
  <script type="module" src="javascripts/main.js"></script>
165
166
  </body>
@@ -1,10 +1,11 @@
1
- const { localStorage } = globalThis;
1
+ const { localStorage, URL } = globalThis;
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
  import { minidenticonSvg } from 'minidenticons';
4
4
  import { agentTopic, agentPersistKey } from './versions.js';
5
5
  import { Int } from './translations.js';
6
6
  import { consume, openDisplay } from './display.js';
7
7
  import { networkPromise, resetInactivityTimer, clickTip, tooltip } from './main.js';
8
+ import { dht } from './protocol.js';
8
9
  import { P2PWebNetwork } from './p2pWebNetwork.js';
9
10
 
10
11
  export class Agent {
@@ -53,23 +54,30 @@ export class Agent {
53
54
  const value = localStorage.getItem(this.localPersistKey(type, tag));
54
55
  this.updateValue(value, scope, type, false); // Don't publish until we post.
55
56
  }
56
- trackedRegions = {};
57
- currentRegion = null;
58
- trackPublicChanges(region) {
59
- this.currentRegion = region;
60
- if (this.trackedRegions[region]) return;
61
- if (Agent.isMine(this.tag)) this.persistPublicMetadata(region);
57
+
58
+ // A map may show multiple regions, and we want to show consistent attribution metadata for alerts in each.
59
+ // Thus we have to keep track of each region where we have activity, and subscribe to changes in each such region for
60
+ // each mentioned avatar instance. See trackPublicChanges.
61
+ //
62
+ // For our agent(s), we also need to keep track of the current msgId so that we can kill it properly when we change value.
63
+ // This will be different in each tracked region.
64
+ //
65
+ trackedRegions = {}; // region code => {avatar, handle} last msgIds for this agent as known.
66
+ trackPublicChanges(region) { // Subscribe to changes for this agent instance's metdata in this region.
67
+ // We do this for for each Agent.ensure that appears for an alert or reply.
68
+ // and also for our own Agent.current when subscribing to a topic, so that we have the right killTag even if we have not published THIS session.
69
+ if (this.trackedRegions[region]) return; // Already subscribed.
62
70
  networkPromise.then(contact => {
63
- this.trackedRegions[region] = true;
71
+ this.trackedRegions[region] = {};
64
72
  const owner = this.tag;
65
73
  ['handle', 'avatar'].forEach(type => {
66
74
  const eventName = this.networkPersistKey(type);
67
- contact.subscribe({eventName, region, owner, since: 'latest', handler: data => this.setPublicData({...data, type})});
75
+ contact.subscribe({eventName, region, owner, since: 'latest', handler: data => this.setPublicData({...data, type, region})});
68
76
  });
69
77
  });
70
78
  }
71
- async setPublicData(data) { // Subscription to public data has fired. Update value, but do not not re-publish.
72
- let {payload, tag, type, topic, ts} = data; // fixme remove topic and ts
79
+ async setPublicData(data) { // Subscription to public data has fired. Update value, but do not not re-publish (e.g., if our agent).
80
+ let {payload, tag, type, region, topic, ts} = data;
73
81
  // WARNING: IF we chunkify avatars, and we use since:'all', then we need lock out asynchronous
74
82
  // decoding of later timestamps, or of null payloads.
75
83
  // if (payload && (type === 'avatar')) {
@@ -78,7 +86,8 @@ export class Agent {
78
86
  // payload = dataURL;
79
87
  // }
80
88
  this.updateValue(payload, 'public', type, false);
81
- if (tag) this.publicMsgId[type] = tag;
89
+ if (payload) this.trackedRegions[region][type] = tag;
90
+ else delete this.trackedRegions[region][type];
82
91
  }
83
92
 
84
93
  static agents = {}; // tag => Agent
@@ -93,12 +102,12 @@ export class Agent {
93
102
  handle: {system: null, public: null, private: null, mixed: null},
94
103
  avatar: {system: null, public: null, private: null, mixed: null}
95
104
  };
96
- publicMsgId = {}; // maps type => msgId for saved public data of this Agent instance.
97
105
  getValue(scope, type) {
98
106
  return this.values[type][scope];
99
107
  }
100
108
  updateValue(value, scope, type, pushPublic = true) { // Updates dependent elements, and if necessary, the mixed values/elements as well.
101
- if (this.values[type][scope] === value) return;
109
+ const match = this.values[type][scope] === value;
110
+ if (match) return null;
102
111
 
103
112
  // Persist if private. For public, update locally but do not publish until this agent publishes an alert or reply.
104
113
  if (scope === 'private') this.persistPrivate(value, type);
@@ -120,33 +129,41 @@ export class Agent {
120
129
  if (value === null) localStorage.removeItem(key);
121
130
  else localStorage.setItem(key, value);
122
131
  }
123
- async persistPublicMetadata(region) { // Publish handle and avatar.
124
- this.currentRegion = region;
132
+ async persistPublicMetadata() { // Publish handle and avatar.
133
+ // Used by Agent.current when we post an alert or reply.
134
+ // There could already be such a publication active, but republishing ensures that it is as fresh as
135
+ // the alert/reply (e.g., with respect to expirations).
125
136
  await Promise.all(['handle', 'avatar'].map(type => this.persistPublic(this.getValue('public', type) || null, type)));
126
137
  }
127
138
  async persistPublic(value, type) { // Publish (and we will act on subscription).
139
+ // We persist to ALL our tracked regions, so that anyone in those regions will get the latest metadata.
140
+ // Note that we might be tracking regions A and B, while someone else is tracking B and C.
141
+ // The other user will get the new value through B, and that will be THE value displayed on their machine.
142
+ // BUT... if the other user tracks B and gets the new value, and then moves into region C and gets a
143
+ // stale value, they will see the stale value in both regions going forward in that session until we update in that region.
128
144
  const eventName = this.networkPersistKey(type);
129
- const region = this.currentRegion;
130
145
  const owner = this.tag;
131
146
  const contact = await networkPromise;
132
147
 
133
- // First kill previous, if any.
134
- // Publish doesn't know whether subscribers will be since all or latest, so it must retain all unkilled.
135
- // Thus if we only kill the last one when there is no value,
136
- // a subscribe since:latest will produce the PREVIOUS value -- the last unkilled one.
148
+ for (let region in this.trackedRegions) {
149
+ // First kill previous, if any.
150
+ // Publish doesn't know whether subscribers will be 'since' all or 'latest', so it must retain all unkilled.
151
+ // Thus if we only kill the last one when there is no value,
152
+ // a subscribe since:latest will produce the PREVIOUS value -- the last unkilled one.
137
153
 
138
- const killTag = this.publicMsgId[type];
139
- console.log('persist', {type, killTag, value: value && value.slice(0, 15)});
140
- if (killTag) await contact.publish({eventName, region, owner, killTag, payload: null});
154
+ const killTag = this.trackedRegions[region][type];
155
+ if (killTag) await contact.publish({eventName, region, owner, killTag, payload: null});
141
156
 
142
- if (!value) return null;
143
- let payload = value;
144
- // Our downsampling is such that we do not need to chunkify.
145
- // if (type === 'avatar') {
146
- // const blob = await P2PWebNetwork.dataURL2blob(value);
147
- // payload = (await contact.chunkifyBlob({blob, region})).topic;
148
- // }
149
- return contact.publish({eventName, region, owner, payload});
157
+ if (value) {
158
+ let payload = value;
159
+ // Our downsampling is such that we do not need to chunkify.
160
+ // if (type === 'avatar') {
161
+ // const blob = await P2PWebNetwork.dataURL2blob(value);
162
+ // payload = (await contact.chunkifyBlob({blob, region})).topic;
163
+ // }
164
+ contact.publish({eventName, region, owner, payload});
165
+ }
166
+ }
150
167
  }
151
168
 
152
169
  // We represent handles and avatars by inserting stuff into given elements.
@@ -275,14 +292,16 @@ export class Agent {
275
292
  static current = null;
276
293
  static tag = null;
277
294
  static identity = null;
295
+ // Keep user separate between dht=1 or empty (Axona) vs dht=0 or -1 (no Axona, for testing).
296
+ static usertagKey = `usertag${(dht <= 0) ? '0' : ''}`;
278
297
  static switchUser(tag, identity) { // Set/persist/ensure the current user, return Agent
279
298
  this.tag = tag; // Before the ensure().
280
299
  this.identity = P2PWebNetwork.currentPublishIdentity = identity;
281
- localStorage.setItem('usertag', this.tag);
300
+ localStorage.setItem(this.usertagKey, this.tag);
282
301
  return this.current = this.ensure({tag, identity});
283
302
  }
284
303
  static async initialize() { // Initialize what the agent needs from the about screen
285
- let tag = localStorage.getItem('usertag');
304
+ let tag = localStorage.getItem(this.usertagKey);
286
305
  const persistAs = tag || uuidv4(); // Give it SOMETHING to persistAs.
287
306
  const myIdentity = await P2PWebNetwork.createAuthorIdentity({persistAs});
288
307
  if (tag !== persistAs) { // Fix up persistence by moving it to where it need to go.
@@ -328,4 +347,3 @@ export class Agent {
328
347
  });
329
348
  }
330
349
  }
331
-
@@ -7,8 +7,8 @@ import { consume } from './display.js';
7
7
  import { Hashtags } from './hashtags.js';
8
8
  import { Agent } from './agent.js';
9
9
  import { Conversation, Reply } from './conversation.js';
10
- import { alertTopic, topicRegion, topicCell } from './versions.js';
11
- import { getContainingCells, getSubdivision, findCoverCellsByCenterAndPoint } from './s2.js';
10
+ import { alertTopic, topicRegion, topicCell, cellHex } from './versions.js';
11
+ import { getContainingCells, getSubdivision, findCoverCellsByMinMaxLatLng } from './s2.js';
12
12
  const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
13
13
 
14
14
 
@@ -112,15 +112,22 @@ export class Alert extends Conversation { // A wrapper around L.marker
112
112
  const center = map.getCenter();
113
113
  const bounds = map.getBounds();
114
114
  const northEast = bounds.getNorthEast();
115
- const newCells = findCoverCellsByCenterAndPoint(center.lat, center.lng, northEast.lat, northEast.lng); // array of cell IDs (BigInts)
115
+ const southWest = bounds.getSouthWest();
116
+ const zoom = map.getZoom();
117
+ const newCells = findCoverCellsByMinMaxLatLng({
118
+ full: zoom <= map.options.minZoom,
119
+ minLat: southWest.lat,
120
+ maxLat: northEast.lat,
121
+ minLng: southWest.lng,
122
+ maxLng: northEast.lng
123
+ });
116
124
  if (!newCells) return null;
117
- const region = P2PWebNetwork.regionCode(center.lat, center.lng);
118
125
  const newKeys = {};
126
+
119
127
  newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => {
120
128
  const eventName = alertTopic(cell, hash);
121
129
  newKeys[eventName] = this.subscriptions[eventName] || 0;
122
130
  }));
123
- Agent.current?.trackPublicChanges(region);
124
131
  // Record a zoomed-out cell id in case next session does not have geolocation services.
125
132
  let level9Cell = getContainingCells(center.lat, center.lng)[9];
126
133
  if (level9Cell !== this.lastLevel9Cell) localStorage.setItem('level9Cell', this.lastLevel9Cell = level9Cell);
@@ -162,6 +169,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
162
169
  }
163
170
  });
164
171
  const region = topicRegion(key);
172
+ Agent.current?.trackPublicChanges(region); // Background. No need to await.
165
173
  await contact.subscribe({eventName: key, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
166
174
  };
167
175
  console.log('updating subscriptions', {newKeys, oldKeys});
@@ -176,7 +184,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
176
184
  // Publish an alert to all applicable eventNames, canceling as required. Promises tag (msgId).
177
185
  static async publish({lat, lng,
178
186
  originalPosting = undefined,
179
- hashtag = Hashtags.getPublish(),
187
+ hashtag = Hashtags.getPublish(true),
180
188
  payload = {lat, lng, originalPosting}, // If payload is null (cancels subject), lat & lng are still used to generate eventNames.
181
189
  cancel = undefined, // First unpublish the specified data, if any. Complicated default.
182
190
  issuedTime = Date.now(), subject,
@@ -222,6 +230,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
222
230
  for (const cell of cells) {
223
231
  const eventName = alertTopic(cell, hashtag);
224
232
  if (payload) {
233
+ // The Axona message will be {hashtag, issuedTime, payload:{lat, lng, originalPosting}}
234
+ // and when combined with the publisher's authorId will be unique to this user/time/hashtag,
235
+ // and yet the same for each of the individual publications at the different s2 scales.
225
236
  const msgId = await contact.publish({eventName, region, payload, issuedTime, hashtag, ...rest});
226
237
  if (subject && subject !== msgId) throw new Error(`msgId is drifting: ${subject} => ${msgId}`);
227
238
  subject = msgId;
@@ -303,6 +314,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
303
314
  }
304
315
  initialize({payload, hashtag, subject, agent, issuedTime, ...rest}) { // Set up the marker for a newly received alert.
305
316
  if (!payload) return null; // Do not cache. E.g., Received a delete event without the initial creation.
317
+ if (!Hashtags.isSubscribed(hashtag)) return null; // A subscribed event may have been in flight while unsubscribing.
306
318
  const icon = this.constructor.makeIcon(hashtag);
307
319
  const {lat, lng, originalPosting} = payload;
308
320
  const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
@@ -513,7 +525,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
513
525
  payload = {message: payload, file};
514
526
  }
515
527
  await contact.publish({eventName: subject, region, payload}); // Publish the new reply.
516
- Agent.current.persistPublicMetadata(region);
528
+ Agent.current.persistPublicMetadata();
517
529
  }
518
530
  deleteReply(replyElement) {
519
531
  resetInactivityTimer();
@@ -543,7 +555,11 @@ export class Alert extends Conversation { // A wrapper around L.marker
543
555
  const icon = new URL('./images/civil-defense-192.png', location.href).href;
544
556
  const url = getShareableURL(alert, [hashtag]).href; // For opening page when it has been closed.
545
557
  const data = {lat, lng, url};
546
- const options = {icon, timestamp, tag: alert, body, data};
558
+ // It appears that on 8/14/26:
559
+ // Safari ignores tag/renotify, and ALWAYS tells the user and displays each notification separately, without consolidating by tag.
560
+ // Chrome ignores renotify, and ALWAYS consolidates by tag, replacing old body with new, and NEVER renotifies the user (for the same tag).
561
+ // So... we could get uniform behavior by skipping the tag, but for now we'll try using it as intended, in case the browsers ever start to comply.
562
+ const options = {icon, timestamp, tag: alert, body, data, renotify: true};
547
563
  console.log('showNotification', hashtag, options);
548
564
  registration.showNotification(hashtag, options);
549
565
  });
@@ -556,8 +572,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
556
572
  const formatReply = ({subject, payload, ...rest}) => {
557
573
  const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
558
574
  let text = message
559
- .replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}"></audio>`) // show audio urls as players
560
- .replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}"></video>`) // show video urls as players
575
+ .replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}" crossorigin="anonymous"></audio>`) // show audio urls as players
576
+ .replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}" crossorigin="anonymous"></video>`) // show video urls as players
561
577
  .replace(/(?<!")https?:\/\/\S+/g, url => `<a href="${url}" target="yz.sidebar">${url}</a>`); // show urls as links
562
578
  let attachment = '';
563
579
  if (file?.startsWith?.('data:image')) attachment = `<a href="${file}" download="${name}"><img class="attachment" src="${file}"></img></a>`;
@@ -6,6 +6,7 @@ import { Alert } from './alert.js';
6
6
  import { resetInactivityTimer, clickTip } from './main.js';
7
7
 
8
8
 
9
+ const help = `🆘 ${Int`help`}`;
9
10
  let allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
10
11
  "🍰 cake",
11
12
  "🎸 classic rock",
@@ -15,7 +16,7 @@ let allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
15
16
  "🎧 edm",
16
17
  "🔥 fire",
17
18
  "🌊 flood",
18
- "🆘 help",
19
+ "${help}",
19
20
  "🎤 hiphop",
20
21
  "🧊 ice",
21
22
  "🎷 jazz",
@@ -81,14 +82,19 @@ export const Hashtags = {
81
82
  getSubscribe() { // Return a list of the hashtags to which the user intendeds to subscribe.
82
83
  return this.getAll().filter(tag => this.hashtags[tag]);
83
84
  },
85
+ isSubscribed(key) {
86
+ return this.hashtags[key];
87
+ },
84
88
  isPublish(key) {
85
89
  return this.hashtags[key] === 'pub';
86
90
  },
87
- getPublish() { // Return the one hashtag to which the user intends to publish.
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.
88
94
  let pub = this.getAll().find(key => this.isPublish(key));
89
- if (!pub) {
90
- pub = this.lastRemainingPublisher || "🆘 help";
91
- this.hashtags[pub] = 'pub';
95
+ if (!pub && force) {
96
+ pub = this.backupPublisher || help;
97
+ this.setPublish(pub);
92
98
  this.onchange({highlightPublish: true});
93
99
  }
94
100
  return pub;
@@ -115,8 +121,10 @@ export const Hashtags = {
115
121
  if (redisplaySubscribers) this.resetSubscriberDisplay();
116
122
  localStorage.setItem('hashtags', JSON.stringify(this.hashtags));
117
123
  if (resetSubscriptions) {
118
- 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.
119
126
  Object.values(Alert.items).forEach(wrapper => this.hashtags[wrapper.hashtag] || wrapper.destroy());
127
+ Alert.updateSubscriptions();
120
128
  }
121
129
  },
122
130
  chipset: document.body.querySelector('.watching-hashtags'), // Element containing the user's chips.
@@ -125,8 +133,9 @@ export const Hashtags = {
125
133
  return `<md-filter-chip label="${label}" elevated removable
126
134
  ${active === 'pub' ? 'class="pub"' : ''}
127
135
  ${active ? ' selected' : ''}
128
- >${this.firstEmoji(label) ? '' : this.identicon(label, 'selected-icon')}
129
- <md-icon-button slot="remove-trailing-icon" title="fixme help"><md-icon class="material-icons"></md-icon></md-icon-button>
136
+ >${this.firstEmoji(label) ? '' :
137
+ `<div slot="selected-icon" class="identicon"><md-icon class="material-icons">checkmark</md-icon> ${this.identicon(label)}</div>`}
138
+ <md-icon-button slot="remove-trailing-icon"><md-icon class="material-icons"></md-icon></md-icon-button>
130
139
  </md-filter-chip>`;
131
140
  },
132
141
 
@@ -234,8 +243,10 @@ export const Hashtags = {
234
243
  li.id = `tag-option-${i}`;
235
244
  li.setAttribute('role', 'option');
236
245
  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
246
+ li.onclick = event => {
247
+ // pointerdown would fire before the text field's blur event,
248
+ // so we would not have to delay that. But then we would not
249
+ // scroll properly by touch drag.
239
250
  event.preventDefault();
240
251
  event.stopPropagation();
241
252
  this.selectValue(item);
@@ -295,7 +306,6 @@ export const Hashtags = {
295
306
  });
296
307
  this.chipset.insertAdjacentHTML("afterbegin", // Chip to add a new hashtag.
297
308
  `<div class="combobox">
298
- <ul class="combobox-listbox hidden" id="knownTagsListbox" role="listbox"></ul>
299
309
  <md-filled-text-field class="newtag"
300
310
  aria-expanded="false"
301
311
  aria-controls="knownTagsListbox"
@@ -307,7 +317,7 @@ export const Hashtags = {
307
317
  // I've tried also supplying a datalist, e.g., to supply the mobile keyboard completions, but
308
318
  // I have not been able to get it to work.
309
319
  const newtag = this.newtag = this.chipset.querySelector('.newtag');
310
- const listbox = this.listbox = this.chipset.querySelector('.combobox-listbox');
320
+ const listbox = this.listbox = document.querySelector('.combobox-listbox');
311
321
  clickTip(newtag, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
312
322
  event.stopPropagation();
313
323
  Alert.closePopup();
@@ -353,8 +363,10 @@ export const Hashtags = {
353
363
  }
354
364
  };
355
365
  newtag.oninput = () => this.renderSelector(newtag.value);
356
- newtag.onblur = () => this.closeSelector();
357
- //newtag.onchange = () => this.acceptTag();
366
+ // When we click on the listbox, the browser will first blur newtag, and then
367
+ // we would not get the click! So here we delay closing a bit.
368
+ newtag.onblur = () => setTimeout(() => this.closeSelector(), 200);
369
+ newtag.onchange = () => this.acceptTag();
358
370
  },
359
371
  remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
360
372
  delete this.hashtags[chip.label];
@@ -364,25 +376,28 @@ export const Hashtags = {
364
376
  toggleChip(chip) { // Switch whether the topic is or is not subscribed.
365
377
  // Now selected => hashtags[label] becomes 'pub' (selected and the publisher) and clear old pub
366
378
  // NOT now selected => hashtags[label] becomes false (through mechanism as follows)
367
- // but if publisher => set alt publisher if possible, else remember as lastActivePublisher
379
+ // but if publisher => set alt publisher if possible, else remember as backupPublisher
368
380
  const label = chip.label;
369
381
 
370
382
  // chip.selected is new state, after clicking.
371
383
  if (chip.selected) return this.setPublish(label); // Become publisher, clearing old publisher.
372
384
 
385
+ // Not selected:
386
+
373
387
  // If we're not publisher, just clear. But don't go through getPublish, as that can have side effects.
374
388
  if (this.hashtags[label] !== 'pub') return this.hashtags[label] = false;
375
389
 
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);
390
+ // Also clear, but...
391
+ const subs = this.getSubscribe();
392
+ if (subs.length > 1) { // Find and set alternative publisher if possible.
393
+ const pubIndex = subs.indexOf(label);
394
+ const index = (pubIndex + 1) % subs.length;
395
+ this.setPublish(subs[index]);
396
+ } else {
397
+ // No alternative available. Clear it, but remember for use by getPublish.
398
+ // It will stay .pub styled while toggled, until anything toggles on.
399
+ this.backupPublisher = label;
382
400
  }
383
-
384
- // No alternative. Clear it, but remember for use by getPublish.
385
- this.lastRemainingPublisher = label;
386
401
  return this.hashtags[label] = false;
387
402
  },
388
403
  getChip(label) { // Handy for scripting, but not otherwise used in app.
@@ -391,13 +406,18 @@ export const Hashtags = {
391
406
  }
392
407
  return null;
393
408
  },
394
- setPublish(newTag, isOldTagNowSubscribed = true) { // Make this topic be the one to be used when we next publish an alert.
395
- const oldTag = this.getPublish();
396
- if (oldTag) this.hashtags[oldTag] = isOldTagNowSubscribed;
409
+ setPublish(newTag) { // Make this topic be the one to be used when we next publish an alert.
410
+ // newTag will be marked for publishing (in this.hashtags and element style)
411
+ // Old publish tag (if any) will be set back to merely be subscribed (in same)
412
+ let oldTag = this.getPublish();
413
+ const backup = this.backupPublisher;
414
+ if (oldTag) this.hashtags[oldTag] = true; // true (instead of 'pub')
415
+ else if (backup) oldTag = backup;
416
+ this.backupPublisher = false;
397
417
  this.hashtags[newTag] = 'pub';
398
418
  for (const chip of this.chipset.children) {
399
- if (chip.label === oldTag) chip.classList.remove('pub');
400
- else if (chip.label === newTag) chip.classList.add('pub');
419
+ if (chip.label === newTag) chip.classList.add('pub');
420
+ else if (chip.label === oldTag) chip.classList.remove('pub');
401
421
  }
402
422
  return oldTag;
403
423
  }
@@ -406,5 +426,5 @@ globalThis.Hashtags = Hashtags; // for debugging
406
426
 
407
427
  // Populate hashtags data and display.
408
428
  // First the persisted/default data:
409
- const persisted = JSON.parse(localStorage.getItem('hashtags') || `{"🍰 ${Int`cake`}": true, "🆘 ${Int`help`}": "pub"}`);
429
+ const persisted = JSON.parse(localStorage.getItem('hashtags') || `{"🍰 ${Int`cake`}": true, "${help}": "pub"}`);
410
430
  Object.entries(persisted).forEach(([tag, active]) => Hashtags.add(tag, active, false));
@@ -237,12 +237,12 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
237
237
  if (level9Cell) { // Zoomed out near where we last where, but not too exact for security.
238
238
  zoom = 12;
239
239
  [lat, lng] = getPointInCell(BigInt(level9Cell));
240
- } else {
240
+ } else { // If the user doesn't want to turn on geolocation, we whould certainly not use ipinfo.io or the like.
241
241
  zoom = 13;
242
242
  [lat, lng] = [37.7749, -122.4194]; // San Fransisco
243
243
  }
244
244
  }
245
- console.log('initializeGeolocation updateLocation');
245
+ //console.log('initializeGeolocation updateLocation');
246
246
  updateLocation(lat, lng, zoom, positionLabel);
247
247
  if (!subscribeOneShot) return;
248
248
  subscribeOneShot = false;
@@ -258,7 +258,7 @@ function initializeGeolocation(subscribe = false) { // Arrange to constantly upd
258
258
  positionWatch = geolocation.watchPosition(
259
259
  position => {
260
260
  const {latitude, longitude} = position.coords;
261
- 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.');
262
262
  initMap(latitude, longitude);
263
263
  }, error => {
264
264
  geolocation.clearWatch(positionWatch);
@@ -83,7 +83,7 @@ export var trackMap;
83
83
  export function initMap(lat, lng, zoom, positionLabel) { // Set up appropriate zoomed initial map and handlers for this position.
84
84
  // Then show initial message and updateSubscriptions.
85
85
 
86
- P2PWebNetwork.setSessionRegion({lat, lng});
86
+ P2PWebNetwork.setSessionLocation({lat, lng});
87
87
 
88
88
  // Map will be centered at the given current location marker, unless overriden by query parameters.
89
89
  let center = {lat, lng};
@@ -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,6 +1,6 @@
1
1
  import { v4 as uuidv4 } from 'uuid';
2
- import { connect, createNodeIdentity, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from '@axona/protocol';
3
- import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from '@axona/protocol/std';
2
+ import { connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION, dht } from './protocol.js';
3
+ import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from './protocol.js';
4
4
 
5
5
  if (!Uint8Array.prototype.toBase64) { // NodeJS < 24
6
6
  Object.defineProperty(Uint8Array.prototype, 'toBase64', {
@@ -16,33 +16,38 @@ const { BigInt, URL, File, pica } = globalThis;
16
16
  await network.disconnect();
17
17
  */
18
18
 
19
- const {promise:sessionRegionPromise, resolve:resolveSessionRegion} = Promise.withResolvers();
19
+ const {promise:sessionLocationPromise, resolve:resolveSessionLocation} = Promise.withResolvers();
20
20
 
21
21
  export class P2PWebNetwork {
22
22
  static wireVersion = WIRE_VERSION;
23
23
  static kernelVersion = KERNEL_VERSION;
24
24
  static createAuthorIdentity = createAuthorIdentity;
25
- static setSessionRegion = resolveSessionRegion;
26
- static sessionRegion = sessionRegionPromise;
25
+ static setSessionLocation = resolveSessionLocation;
26
+ static sessionLocation = sessionLocationPromise;
27
27
  static async create({infoLogger = console.log, debugLogger,
28
- region = this.sessionRegion,
28
+ location = this.sessionLocation,
29
29
  bridgeUrl = (globalThis.location && new URL(globalThis.location).searchParams.get('bridge')) ||
30
30
  globalThis.process?.env.BRIDGE_URL ||
31
+ ((dht <= 0) && // fixme remove when we host bridges
32
+ (globalThis.location?.origin.replace(/^http/, 'ws') ||
33
+ 'ws://localhost:3000')) ||
31
34
  'wss://bridge.axona.net',
32
35
  } = {}) {
33
36
  // Promise a ready-to-use network peer.
34
- region = await region;
37
+ location = await location;
38
+
39
+ const network = new this();
40
+ network.resetStatePromises();
35
41
 
36
42
  const { peer, nodeIdentity, transport, status, disconnect } = await connect({
37
43
  bridge: bridgeUrl,
38
- location: region,
44
+ location,
45
+ onDisconnect: network.detached,
39
46
  author: false
40
47
  });
41
-
42
- const network = new this();
43
48
  Object.assign(network, {infoLogger, debugLogger, disconnector: disconnect, transport, nodeIdentity, peer});
44
- network.resetStatePromises();
45
- network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(region.lat, region.lng).toString(16)}.`);
49
+
50
+ network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(location.lat, location.lng).toString(16)}.`);
46
51
  peer.onError(error => {
47
52
  network.info(`error: ${error.message || error}`);
48
53
  throw error;
@@ -189,7 +194,7 @@ export class P2PWebNetwork {
189
194
  };
190
195
  await this.peer.sub(topic, callback, {since});
191
196
  } else {
192
- this.peer.unsub(topic, {});
197
+ await this.peer.unsub(topic, {});
193
198
  }
194
199
  }
195
200
  static currentPublishIdentity = null;
@@ -217,7 +222,7 @@ export class P2PWebNetwork {
217
222
  static regionCode(lat, lng) { // Answer containing region code.
218
223
  return geoCellId(lat, lng);
219
224
  }
220
- static regionCenter(regionCode) {
225
+ static regionCenter(regionCode) { // {lat, lng} of center of regionCode
221
226
  return geoCellCenter(regionCode);
222
227
  }
223
228
  static delay(ms, result) { // Promise result after ms milliseconds.
@@ -0,0 +1,173 @@
1
+ // If ?dht=0, use a websocket to the server instead of Axona.
2
+ const { TextEncoder, TextDecoder, BigInt, URL, WebSocket, Buffer } = globalThis;
3
+
4
+ let connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION, stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes;
5
+
6
+ // dht 1 -> Axona
7
+ // dht 0 -> server
8
+ // dht -1 -> in-memory on client only
9
+ const defaultDHT = 1;
10
+ export const dht = parseInt((globalThis.process ?
11
+ globalThis.process.env.DHT :
12
+ new URL(globalThis.location).searchParams.get('dht')) ?? defaultDHT);
13
+
14
+ if (dht < 1) {
15
+
16
+ const { v4:uuidv4 } = await import('uuid');
17
+ const { getContainingCells, getPointInCell } = await import('./s2.js');
18
+ const { cellHex } = await import('./versions.js');
19
+ const operator = await import('./pubsub.js');
20
+
21
+ WIRE_VERSION = 'SERVER';
22
+ KERNEL_VERSION = `${WIRE_VERSION}.1.0`;
23
+ createAuthorIdentity = ({
24
+ persistAs, store = {
25
+ get: (key) => globalThis.localStorage.getItem(key),
26
+ set: (key, value) => globalThis.localStorage.setItem(key, value)
27
+ }}) => {
28
+ let tag;
29
+ if (persistAs) {
30
+ tag = store.get(persistAs);
31
+ if (!tag) {
32
+ tag = uuidv4();
33
+ store.set(persistAs, tag);
34
+ } else if (tag.includes('pubkey')) {
35
+ tag = JSON.parse(tag).pubkey; // if it is a real dump, as for alert-bot.
36
+ }
37
+ }
38
+ return {authorId: tag};
39
+ };
40
+ geoCellId = (lat, lng) => {
41
+ const cells = getContainingCells(lat, lng);
42
+ const hex = cellHex(cells[0]);
43
+ const sliced = hex.slice(0, 2);
44
+ return parseInt(sliced, 16); // The worst way to do this.
45
+ };
46
+ geoCellCenter = regionCode => { // Not right. See getPointInCell comments.
47
+ const expanded = regionCode.toString(16).padStart(2, '0').padEnd(16, '0');
48
+ const cellid = BigInt('0x' + expanded);
49
+ const [lat, lng] = getPointInCell(cellid);
50
+ return {lat, lng};
51
+ };
52
+
53
+ bytesToString = u8 => {
54
+ return new TextDecoder().decode(u8);
55
+ };
56
+ stringToBytes = (str) => {
57
+ return new TextEncoder().encode(str);
58
+ };
59
+ const hasBuffer = typeof Buffer !== 'undefined';
60
+ function bytesToB64(u8) {
61
+ if (hasBuffer) return Buffer.from(u8).toString('base64');
62
+ let s = ''; const CH = 0x8000;
63
+ for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH));
64
+ return btoa(s);
65
+ }
66
+ function b64ToBytes(b64) {
67
+ if (hasBuffer) return new Uint8Array(Buffer.from(b64, 'base64'));
68
+ const s = atob(b64); const u8 = new Uint8Array(s.length);
69
+ for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i);
70
+ return u8;
71
+ }
72
+ publishChunkedBytes = (peer, u8, {name, mime}) => {
73
+ const str = bytesToB64(u8);
74
+ return {topic: {name, mime, str}};
75
+ };
76
+ receiveChunkedBytes = (peer, {name, mime, str}, options) => {
77
+ const u8 = b64ToBytes(str);
78
+ return {bytes: u8, name, mime, msgIds: []};
79
+ };
80
+
81
+ connect = async ({bridge, location, onDisconnect}) => {
82
+ // We always call location as {lat, lng}
83
+ // We always call it with author:false
84
+ const {lat, lng} = location;
85
+ const region = geoCellId(lat, lng);
86
+ const nodeTag = region.toString(16).padStart(2, '0') + uuidv4();
87
+ const nodeIdentity = {id: nodeTag};
88
+ const handlers = {}; // guid => handler tag
89
+ const inFlight = {};
90
+ let disconnect, transport; // But do not call P2PWebNetwork.ice!!!
91
+ const send = await new Promise(resolve => {
92
+ if (dht === 0) {
93
+ const url = `${bridge}/${nodeTag}`;
94
+ const socket = transport = new WebSocket(url);
95
+ socket.onmessage = event => {
96
+ const [tag, ...rest] = JSON.parse(event.data);
97
+ const subHandler = handlers[tag];
98
+ const inFlightResolver = inFlight[tag];
99
+
100
+ if ((!subHandler && !inFlightResolver) || // debug
101
+ ((typeof(subHandler) !== 'function') && (typeof(inFlightResolver) !== 'function')))
102
+ console.log({tag, rest, subHandler, inFlightResolver, handlers, inFlight});
103
+
104
+ if (subHandler) return subHandler(...rest);
105
+ delete inFlight[tag];
106
+ return inFlightResolver?.(...rest);
107
+ };
108
+ socket.onopen = () => {
109
+ if (socket.readyState !== WebSocket.OPEN) return; // You would think that can't happen, but...
110
+ resolve((...rest) => { // send()
111
+ const tag = uuidv4();
112
+ const {promise, resolve} = Promise.withResolvers();
113
+ inFlight[tag] = resolve;
114
+ socket.send(JSON.stringify([tag, ...rest]));
115
+ return promise;
116
+ });
117
+ };
118
+ // onerror is of no help, as the event is generic.
119
+ socket.onclose = event => {
120
+ console.warn('websocket close', event.code, event.wasClean, event.reason);
121
+ onDisconnect();
122
+ };
123
+ disconnect = () => socket.close();
124
+ } else {
125
+ disconnect = () => null;
126
+ operator.setReceiver((nodeTag, id, ...rest) => handlers[id](...rest));
127
+ resolve((methodName, ...rest) => operator[methodName](...rest)); // send()
128
+ }
129
+ });
130
+
131
+ const peer = {
132
+ onError() {},
133
+ onLog() {},
134
+ health() {
135
+ return {peers: [], axonRoles: []};
136
+ },
137
+ async leave() {
138
+ await send('deleteSubscriber', nodeTag);
139
+ disconnect();
140
+ },
141
+ async sub(topic, handler, options) {
142
+ const result = await send('subscribe', topic, nodeTag, options);
143
+ handlers[result.id] = handler;
144
+ return result;
145
+ },
146
+ async unsub(topic, options) {
147
+ const result = await send('unsubscribe', topic, nodeTag, options);
148
+ delete handlers[result.id];
149
+ return result;
150
+ },
151
+ async pub(topic, message, options) {
152
+ return send('publish', topic, message, options);
153
+ },
154
+ kill(topic, msgId, options) {
155
+ return send('unpublish', topic, msgId, options);
156
+ },
157
+ host() {},
158
+ unhost() {}
159
+ };
160
+ const status = {peers: 0, ms: 0}; // fixme ms
161
+ return { peer, nodeIdentity, transport, status, disconnect };
162
+ };
163
+
164
+ } else {
165
+ const protocol = await import('@axona/protocol');
166
+ const std = await import('@axona/protocol/std');
167
+ ({ connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } = protocol);
168
+ ({ stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } = std);
169
+ }
170
+
171
+ export { connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION };
172
+ export { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes }
173
+
@@ -0,0 +1,124 @@
1
+ // In memory pubsub, for either client-only testing, or server-websocket testing
2
+ const { v4:uuidv4 } = await import('uuid');
3
+ const { TextEncoder, crypto, Buffer } = globalThis;
4
+
5
+ function setBucket(collection, type, topicId, subject, value) { // Set value in the collection.
6
+ const bucket = collection[type][topicId] ||= {};
7
+ bucket[subject] = value;
8
+ }
9
+ function removeBucket(collection, type, topicId, subject) { // Return the value and stop storing it.
10
+ const bucket = collection[type][topicId];
11
+ if (!bucket) return null;
12
+ const value = bucket[subject];
13
+ delete bucket[subject];
14
+ if (!Object.keys(bucket).length) delete collection[type][topicId];
15
+ return value;
16
+ }
17
+
18
+ const SUBSCRIPTION_TIMEOUT = 0; // No need, because we run deleteSubscriber on disconnect.
19
+ const PUBLISH_TIMEOUT = 24 * 60 * 60e3; // Delete after 24 hours.
20
+ const timeouts = {pub: {}, sub: {}};
21
+ function expire(type, topicId, subject, remover, timeout) { // Cancellably schedule remover() to fire at timeout.
22
+ if (!timeout) return;
23
+ setBucket(timeouts, type, topicId, subject, setTimeout(remover, timeout));
24
+ }
25
+ function cancel(type, topicId, subject) { // Cancel a sheduled expiration.
26
+ clearTimeout(removeBucket(timeouts, type, topicId, subject));
27
+ }
28
+
29
+ // pub maps eventName => {[subject]: storageItem, ...}, where subject is the message id. Entries purged after PUBLISH_TIMEOUT.
30
+ // sub maps eventName => {[subject]: ws, ...}, where subject is the subscriber id. Entries purged after SUBSCRIPTION_TIMEOUT.
31
+ const data = {pub: {}, sub: {}};
32
+ function getDataValues(type, topicId) { // For all subjects
33
+ return Object.values(data[type][topicId] || {});
34
+ }
35
+ function getDataEntries(type, topicId) {
36
+ return Object.entries(data[type][topicId] || {});
37
+ }
38
+
39
+ function deleteSub(topicId, subject) {
40
+ return removeBucket(data, 'sub', topicId, subject);
41
+ }
42
+ function normalizeTopic({name, region, owner, write = 'open'} = {}) {
43
+ if (typeof(region) === 'string') region = parseInt(region);;
44
+ return {name, region, owner, write};
45
+ }
46
+ function deriveTopicId(topic) {
47
+ return JSON.stringify(normalizeTopic(topic)); // No need to hash in this implementation.
48
+ }
49
+
50
+ let invoke;
51
+ export function setReceiver(receiver) {
52
+ invoke = receiver;
53
+ }
54
+
55
+ export function subscribe(topicName, nodeTag, {since = 'all'}) {
56
+ // Axona allows multiple handlers on the same topic, but we don't use that in civildefense, and do not implement it here.
57
+ const topicId = deriveTopicId(topicName);
58
+ const id = uuidv4();
59
+ cancel('sub', topicId, id);
60
+ expire('sub', topicId, id, () => deleteSub(topicId, id), SUBSCRIPTION_TIMEOUT);
61
+ setBucket(data, 'sub', topicId, nodeTag, id);
62
+ if (since) setTimeout(() => { // invoke handler on any sticky data, but only after we have told client the subscription id.
63
+ let lastEnvelope = null, lastTime = 0;
64
+ for (const envelope of getDataValues('pub', topicId)) {
65
+ switch (since) {
66
+ case 'all':
67
+ invoke(nodeTag, id, envelope);
68
+ break;
69
+ case 'latest':
70
+ if (envelope.ts > lastTime) {
71
+ lastTime = envelope.ts;
72
+ lastEnvelope = envelope;
73
+ }
74
+ break;
75
+ default: // Must be a timestamp
76
+ if (envelope.ts === since) invoke(nodeTag, id, envelope);
77
+ }
78
+ }
79
+ if (lastEnvelope) invoke(nodeTag, id, lastEnvelope);
80
+ }, 100);
81
+ return {topicName, topicId, id};
82
+ }
83
+
84
+ export function unsubscribe(topic, nodeTag, options) {
85
+ const topicId = deriveTopicId(topic);
86
+ cancel('sub', topicId, nodeTag);
87
+ const id = deleteSub(topicId, nodeTag);
88
+ return {ok: true, id}; // Axona doesn't return the id(s) of the subscription(s), but it is convenient for us to do so.
89
+ }
90
+
91
+ export function deleteSubscriber(nodeTag) {
92
+ for (const topicId in data.sub) {
93
+ const keySubs = data.sub[topicId];
94
+ for (const [subject, value] of Object.entries(keySubs)) {
95
+ if (nodeTag === subject) deleteSub(topicId, subject, keySubs);
96
+ }
97
+ }
98
+ }
99
+
100
+ const hasBuffer = typeof Buffer !== 'undefined';
101
+ let toHex = hasBuffer ? u8 => Buffer.from(u8).toString('hex') : u8 => u8.toHex();
102
+ export async function publish(topic, message, {signWith}) {
103
+ const topicId = deriveTopicId(topic);
104
+ const signerPubkey = signWith?.authorId || undefined;
105
+ const payload = JSON.stringify({message, publisher: signerPubkey});
106
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload));
107
+ const msgId = toHex(new Uint8Array(hash));
108
+ const envelope = {msgId, topic, ts: Date.now(), message, signerPubkey};
109
+ for (const [nodeTag, id] of getDataEntries('sub', topicId)) invoke(nodeTag, id, envelope);
110
+ setBucket(data, 'pub', topicId, msgId, envelope);
111
+ expire('pub', topicId, msgId, () => removeBucket(data, 'pub', topicId, msgId), PUBLISH_TIMEOUT);
112
+ return msgId;
113
+ }
114
+
115
+ export function unpublish(topic, msgId, {signWith}) {
116
+ const topicId = deriveTopicId(topic);
117
+ cancel('pub', topicId, msgId);
118
+ const envelope = removeBucket(data, 'pub', topicId, msgId);
119
+ if (!envelope) return {ok: false}; // we didn't have it.
120
+ envelope.deleted = true;
121
+ envelope.message = null;
122
+ for (const [nodeTag, id] of getDataEntries('sub', topicId)) invoke(nodeTag, id, envelope);
123
+ return {ok: true};
124
+ }
@@ -1,4 +1,4 @@
1
- import { s2 } from 's2js';
1
+ import { s2, s1, r1 } from 's2js';
2
2
  const { cellid, LatLng, Point, Cell, Cap, RegionCoverer } = s2;
3
3
  import { cellHex } from './versions.js';
4
4
  const { BigInt } = globalThis;
@@ -12,22 +12,30 @@ const { BigInt } = globalThis;
12
12
  // Meanwhile as the user changes the area being shown, we subscribe to whatever cells we need in order to cover the display
13
13
  // area without overlapping cells.
14
14
 
15
- export const MIN_LEVEL = 2; // Corresponds to the top level Axona regions.
15
+ export const MIN_LEVEL = 3; // Corresponds to the top level Axona regions.
16
16
  const MAX_S2_LEVEL = 30; // The leaf level that Cell.fromPoint operates at.
17
17
  const MAX_MAP_LEVEL = 17; // The max level that findCoverCellsByCenterAndRadius will use on our maps.
18
18
 
19
19
  const EARTH_RADIUS_METERS = 6371e3;
20
20
 
21
21
  export function getPointInCell(cellId) { // answer [lat, lng] in degrees from a BigInt
22
- let {lat, lng} = s2.cellid.latLng(cellId);
23
- const degrees = 180 / Math.PI;
24
- lat *= degrees;
25
- lng *= degrees;
26
- return [lat, lng];
22
+ // CAUTION: This is intended for s2 level 3 or finer, and won't always work symmetrically for our top-level regions.
23
+ // e.g. getContainingCells(...getPointInCell( cell for 0x47 )][0] is 0x46!
24
+ let center = s2.cellid.latLng(cellId);
25
+ return [s1.angle.degrees(center.lat), s1.angle.degrees(center.lng)];
27
26
  }
28
27
 
28
+ function getCellSubdivision(cell) {
29
+ return cellid.children(cell);
30
+ }
31
+ function getCellLevel(cell) {
32
+ return cellid.level(cell);
33
+ }
34
+ function getFace(cell) {
35
+ return cellid.face(cell);
36
+ }
29
37
  export function getSubdivision(hexString) {
30
- return cellid.children(BigInt('0x' + hexString)).map(childCell => cellHex(childCell));
38
+ return getCellSubdivision(BigInt('0x' + hexString)).map(childCell => cellHex(childCell));
31
39
  }
32
40
 
33
41
  // Return a list of the cell ids that contain the point.
@@ -43,39 +51,22 @@ export function getContainingCells(lat, lng) {
43
51
  return cells.slice(MIN_LEVEL, MAX_MAP_LEVEL + 1); // We can only make use between Axona region size and the smallest region our maps subscribe to.
44
52
  }
45
53
 
46
- // Return a list of cell ids that covers a circle specified by a center and a point on that circle, without overlapped cells.
47
- export function findCoverCellsByCenterAndPoint(centerLat, centerLng, pointLat, pointLng) {
48
- // TODO: Does it make sense to do this by the actual borders shown, rather than by the length of the half-diagonal?
49
- const center = Point.fromLatLng(LatLng.fromDegrees(centerLat, centerLng));
50
- const point = Point.fromLatLng(LatLng.fromDegrees(pointLat, pointLng));
51
- const distanceAngle = center.distance(point);
52
- const interestRadiusMeters = distanceAngle * EARTH_RADIUS_METERS;
53
- return findCoverCellsByCenterAndRadius(centerLat, centerLng, interestRadiusMeters);
54
- }
54
+ export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, full = false,
55
+ options:{minLevel = MIN_LEVEL, maxLevel = MAX_MAP_LEVEL, maxCells = 12} = {}}) {
56
+ // Return a list-like object of cell ids that cover the specified range.
57
+ // The lat/lng won't work well for the full map, so an exact full map can be requested, overriding that lat/lng.
55
58
 
56
- // Return a list of cell ids that covers interestRadiusMeters around latitude/longitude, without overlapped cells.
57
- export function findCoverCellsByCenterAndRadius(lat, lng, interestRadiusMeters) {
58
- const point = Point.fromLatLng(LatLng.fromDegrees(lat, lng));
59
+ // There are a lot of ways that seem like they do this, but it is very easy to find something that works for a few cases,
60
+ // but misses cells in some circumstances, so be wary about rewriting this.
61
+ const lo = s2.LatLng.fromDegrees(minLat, Math.max(minLng, -180));
62
+ const hi = s2.LatLng.fromDegrees(maxLat, Math.max(maxLng, 190));
59
63
 
60
- // replicating key parts of Cap.cellUnionBound():
61
- // Find the maximum (i.e., finest-grained) level such that the cap contains at
62
- // most [ael: at least??] one cell vertex and such that CellID.AppendVertexNeighbors() can be called.
63
- const findLevel = radius => {
64
- let levelForRadius = MAX_S2_LEVEL;
65
- const radiusAngle = radius / EARTH_RADIUS_METERS;
66
- if (radiusAngle > 0) {
67
- const deriv = 2 * Math.SQRT2 / 3;
68
- levelForRadius = Math.floor(Math.log2(deriv / radiusAngle));
69
- if (levelForRadius > MAX_S2_LEVEL) levelForRadius = MAX_S2_LEVEL;
70
- if (levelForRadius < 0) levelForRadius = 0;
71
- }
72
- return levelForRadius;
73
- };
74
- const levelForRadius = findLevel(interestRadiusMeters); // - 1; // as seen in cellUnionBound: go one level bigger
64
+ const rect = full ? s2.Rect.fullRect() : new s2.Rect(
65
+ new r1.Interval(lo.lat, hi.lat),
66
+ new r1.Interval(lo.lng, hi.lng)
67
+ );
75
68
 
76
- const minLevel = Math.max(MIN_LEVEL, levelForRadius - 1);
77
- const maxLevel = Math.max(MIN_LEVEL, levelForRadius + 2);
78
- const rc = new RegionCoverer({ minLevel, maxLevel, maxCells: 9 }); // Will exceed maxCells as needed to obey minLevel.
79
- const r = Cap.fromCenterAngle(point, interestRadiusMeters / EARTH_RADIUS_METERS);
80
- return rc.covering(r);
69
+ const coverer = new RegionCoverer({minLevel, maxLevel, maxCells});
70
+ return coverer.covering(rect); // a CellUnion — array-like of bigint cell IDs, already normalized/minimal
81
71
  }
72
+
@@ -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.16';
3
+ const serviceVersion = '4.5.22';
4
4
 
5
5
  const cacheList = [ // The files we need.
6
6
  "/",
@@ -21,6 +21,8 @@ const cacheList = [ // The files we need.
21
21
  "javascripts/translations.js",
22
22
  "javascripts/service-manager.js",
23
23
  "javascripts/p2pWebNetwork.js",
24
+ "javascripts/protocol.js",
25
+ "javascripts/pubsub.js",
24
26
 
25
27
  "stylesheets/style.css",
26
28
 
@@ -34,6 +36,7 @@ const cacheList = [ // The files we need.
34
36
 
35
37
  "axona-protocol/src/index.js",
36
38
  "axona-protocol/src/errors.js",
39
+ "axona-protocol/src/connect.js",
37
40
  "axona-protocol/src/bridgeDirectory.js",
38
41
  "axona-protocol/src/contracts/Transport.js",
39
42
  "axona-protocol/src/contracts/DHT.js",
@@ -49,6 +52,18 @@ const cacheList = [ // The files we need.
49
52
  "axona-protocol/src/dht/Subscription.js",
50
53
  "axona-protocol/src/pubsub/AxonaManager.js",
51
54
  "axona-protocol/src/pubsub/authorClass.js",
55
+ "axona-protocol/src/pubsub/ids.js",
56
+ "axona-protocol/src/pubsub/dispatch.js",
57
+ "axona-protocol/src/pubsub/durability.js",
58
+ "axona-protocol/src/pubsub/rootClaim.js",
59
+ "axona-protocol/src/pubsub/constants.js",
60
+ "axona-protocol/src/pubsub/topicStore.js",
61
+ "axona-protocol/src/pubsub/rootElection.js",
62
+ "axona-protocol/src/pubsub/syncEngine.js",
63
+ "axona-protocol/src/pubsub/repairPlane.js",
64
+ "axona-protocol/src/pubsub/wireHandlers.js",
65
+ "axona-protocol/src/pubsub/writeFlight.js",
66
+ "axona-protocol/src/pubsub/ackProof.js",
52
67
  "axona-protocol/src/pubsub/kill.js",
53
68
  "axona-protocol/src/pubsub/touch.js",
54
69
  "axona-protocol/src/pubsub/post.js",
@@ -166,20 +181,20 @@ self.addEventListener('fetch', event => {
166
181
  async function cacheSource(version, event) { // Cache source in the given version.
167
182
  console.log(`service-worker ${serviceVersion} is caching source in cache ${version}.`);
168
183
  const cache = await caches.open(version);
169
- // for (const file of cacheList) cache.add(new Request(file, {cache: 'no-store'})).catch(error => console.error(file + ' ' + error.message)); // dev testing for changed file tree
170
- await cache.addAll(cacheList.map(name => new Request(name, {cache: 'no-store'}))); // Might not be necessary to specify no-store, but if any browsers insist on their own caching...
184
+ await Promise.all(cacheList.map(file => cache.add(file)
185
+ .catch(error => console.error(file + ' ' + error.message))));
171
186
 
172
187
  await Promise.all([
173
188
  // These are referenced within material web, but missing. Turns out we don't need them,
174
189
  // but let's cache empty responses to keep the console cleaner.
175
- "https://esm.run/npm/lit@3.3.3/+esm",
176
190
  "https://esm.run/npm/tslib@2.8.1/+esm",
191
+ "https://esm.run/npm/lit@3.3.3/+esm",
177
192
  "https://esm.run/npm/lit@3.3.3/static-html.js/+esm",
178
193
  "https://esm.run/npm/lit@3.3.3/decorators.js/+esm",
179
194
  "https://esm.run/npm/lit@3.3.3/directives/style-map.js/+esm",
180
195
  "https://esm.run/npm/lit@3.3.3/directives/class-map.js/+esm",
181
196
  "https://esm.run/npm/lit@3.3.3/directives/when.js/+esm",
182
- "https://esm.run/npm/lit@3.3.1/directives/live.js/+esm",
197
+ "https://esm.run/npm/lit@3.3.3/directives/live.js/+esm",
183
198
  ].map(url => cache.put(new Request(url),
184
199
  new Response("", {headers: { "Content-Type": "text/javascript" }}))));
185
200
  return version;
@@ -16,6 +16,7 @@
16
16
  --md-sys-shape-corner-full: 12px;
17
17
 
18
18
  --about-image-size: 80px;
19
+ --combobox-minWidth: 125px;
19
20
 
20
21
  /* --md-sys-color-tertiary-container: blue; */
21
22
  /* --md-sys-color-background: yellow; */
@@ -196,22 +197,26 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
196
197
  .combobox {
197
198
  position: relative;
198
199
  --textFieldHeight: 34px;
199
- --minWidth: 125px;
200
200
  }
201
201
  .combobox-listbox {
202
202
  padding: 5px;
203
203
  position: absolute;
204
- bottom: calc(var(--textFieldHeight) + 2px);
205
- left: 0;
206
- max-height: 50dvh; /* leaving room for portrait keyboard. landscape keyboard has no room at all */
207
- width: fit-content;
208
- min-width: var(--minWidth);
204
+ right: 5px;
205
+ bottom: 55px;
206
+ max-height: 45dvh; /* leaving room for portrait keyboard. landscape keyboard has no room at all *\/ */
209
207
  overflow-y: auto;
210
- z-index: 9999;
208
+ width: fit-content;
209
+ min-width: var(--combobox-minWidth);
210
+ z-index: 1000;
211
211
  background: rgba(255, 255, 255, 0.95);
212
212
  border-top-left-radius: 12px;
213
213
  border-top-right-radius: 12px;
214
214
  }
215
+ @media (orientation: landscape) and (pointer: coarse) {
216
+ .combobox-listbox {
217
+ display: none !important;
218
+ }
219
+ }
215
220
  .combobox-option {
216
221
  font-size: 0.95rem;
217
222
  color: var(--md-sys-color-primary);
@@ -238,12 +243,13 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
238
243
  background: #B8C2D7;
239
244
  }
240
245
  .watching-hashtags md-filled-text-field.newtag {
246
+ anchor-name: --combobox-newtag;
241
247
  --md-filled-field-top-space: 0px;
242
248
  --md-filled-field-bottom-space: 0px;
243
249
  --md-filled-text-field-leading-space: 8px;
244
250
  --md-filled-text-field-trailing-space: 0px;
245
- height: var(--textFieldHeight);
246
- width: var(--minWidth);
251
+ height: 34px;
252
+ width: var(--combobox-minWidth);
247
253
  }
248
254
  .alert-marker {
249
255
  text-shadow: 8px -3px 10px rgb(0 0 0 / 60%);
@@ -297,9 +303,29 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
297
303
  md-filter-chip {
298
304
  --md-sys-color-surface-container-low: #B8C2D7; /* non-selected elevated filter chip */
299
305
  }
300
- md-filter-chip minidenticon-svg + md-icon-button {
306
+ md-filter-chip minidenticon-svg + md-icon-button,
307
+ md-filter-chip .identicon + md-icon-button {
301
308
  bottom: 10px;
302
309
  }
310
+ md-filter-chip .identicon md-icon {
311
+ font-size: 18px;
312
+ width: 20px;
313
+ position: relative;
314
+ bottom: 3px;
315
+ }
316
+ md-filter-chip .identicon {
317
+ width: 43px;
318
+ }
319
+ md-filter-chip .identicon minidenticon-svg {
320
+ width: 18px;
321
+ display: inline-block;
322
+ position: relative;
323
+ bottom: 5px;
324
+ left: -3px;
325
+ }
326
+ md-filter-chip minidenticon-svg {
327
+ background: white;
328
+ }
303
329
  md-filter-chip md-icon-button {
304
330
  position: relative;
305
331
  bottom: 3px;
@@ -317,9 +343,6 @@ md-filter-chip:not([selected]) md-icon::after {
317
343
  md-filter-chip.pub md-icon {
318
344
  color: var(--md-sys-color-secondary);
319
345
  }
320
- md-filter-chip minidenticon-svg {
321
- background: white;
322
- }
323
346
  md-outlined-icon-button minidenticon-svg, md-outlined-icon-button img {
324
347
  height: 40px;
325
348
  width: 40px;
package/server/app.js CHANGED
@@ -1,11 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import process from 'node:process';
3
- import { exec } from 'node:child_process';
4
3
  import {cpus, availableParallelism } from 'node:os';
5
4
  import cluster from 'node:cluster';
6
- import http from 'node:http';
7
- import express from 'express';
8
- import logger from 'morgan';
9
5
  import yargs from 'yargs';
10
6
  import { hideBin } from 'yargs/helpers';
11
7
  import { resolve } from './dirname.js';
@@ -60,10 +56,17 @@ const debug = argv.verbose && ((...rest) => console.log(new Date(), ...rest));
60
56
  function delay(ms = argv.spacing * 1e3) { return new Promise(resolve => setTimeout(resolve, ms)); }
61
57
 
62
58
  if (cluster.isPrimary) { // Parent process with portal webserver through which clienta can bootstrap
59
+ const http = await import('node:http');
60
+ const express = (await import('express')).default;
61
+ const logger = (await import('morgan')).default;
62
+ const { configureWebsocket } = await import('./websocket.js');
63
+
63
64
  const port = parseInt((new URL(argv.baseURL)).port || '80');
64
65
  process.title = 'yz.social';
65
66
  const app = express();
66
67
  app.use(logger(':date[iso] :status :method :url :res[content-length] - :response-time ms'));
68
+ const server = http.createServer(app);
69
+ configureWebsocket(server);
67
70
 
68
71
  // if (argv.announce) { // The default is to not announce.
69
72
  // let announce = null;
@@ -98,7 +101,7 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
98
101
  extensions: ['js'] // Some dependencies refer to .js files as relative pathnames, with the .js missing.
99
102
  }));
100
103
 
101
- app.listen(port);
104
+ server.listen(port);
102
105
  log(`Listening on ${port} and starting ${argv.nPortals} nodes on ${logicalCores} ${cpus()[0].model} logical cores.`);
103
106
  for (let i = 0; i < argv.nPortals; i++) {
104
107
  cluster.fork();
@@ -116,7 +119,7 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
116
119
  process.title = 'axona-starting';
117
120
  const { P2PWebNetwork, location } = await import('../index.js');
118
121
  const network = await P2PWebNetwork.create({
119
- region: location,
122
+ location,
120
123
  infoLogger: log,
121
124
  debugLogger: debug
122
125
  });
@@ -0,0 +1,46 @@
1
+ // Offer a websocket connection for testing, that skips the DHT entirely, and accepts publishing by pushing to subscribers over the websocket.
2
+ import { WebSocketServer } from 'ws';
3
+ import * as operator from '../public/javascripts/pubsub.js';
4
+
5
+ const sockets = {};
6
+ operator.setReceiver((nodeId, id, envelope) => {
7
+ const socket = sockets[nodeId];
8
+ socket.send(JSON.stringify([id, envelope]));
9
+ });
10
+
11
+ function heartbeat() {
12
+ this.isAlive = true;
13
+ }
14
+
15
+ export function configureWebsocket(server) {
16
+ const wss = new WebSocketServer({ server });
17
+ wss.on('connection', (ws, req) => {
18
+ const nodeTag = req.url.slice(1);
19
+ console.log('Connected', nodeTag);
20
+ sockets[nodeTag] = ws;
21
+
22
+ ws.on('message', async message => {
23
+ const [id, methodName, ...rest] = JSON.parse(message);
24
+ const result = await operator[methodName](...rest);
25
+ ws.send(JSON.stringify([id, result]));
26
+ });
27
+
28
+ ws.isAlive = true;
29
+ ws.on('pong', heartbeat);
30
+
31
+ ws.on('error', console.error);
32
+ ws.on('close', () => {
33
+ console.log('Disconnected', nodeTag);
34
+ operator.deleteSubscriber(nodeTag);
35
+ });
36
+ });
37
+
38
+ const interval = setInterval(function ping() { // Keep-alive ping/pong on interval
39
+ wss.clients.forEach(function each(ws) {
40
+ if (!ws.isAlive) return ws.terminate();
41
+ ws.isAlive = false;
42
+ ws.ping();
43
+ return null;
44
+ });
45
+ }, 30e3);
46
+ }