@yz-social/civildefense.io 4.5.16 → 4.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.21",
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,4 +1,4 @@
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';
@@ -53,23 +53,30 @@ export class Agent {
53
53
  const value = localStorage.getItem(this.localPersistKey(type, tag));
54
54
  this.updateValue(value, scope, type, false); // Don't publish until we post.
55
55
  }
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);
56
+
57
+ // A map may show multiple regions, and we want to show consistent attribution metadata for alerts in each.
58
+ // Thus we have to keep track of each region where we have activity, and subscribe to changes in each such region for
59
+ // each mentioned avatar instance. See trackPublicChanges.
60
+ //
61
+ // 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.
62
+ // This will be different in each tracked region.
63
+ //
64
+ trackedRegions = {}; // region code => {avatar, handle} last msgIds for this agent as known.
65
+ trackPublicChanges(region) { // Subscribe to changes for this agent instance's metdata in this region.
66
+ // We do this for for each Agent.ensure that appears for an alert or reply.
67
+ // 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.
68
+ if (this.trackedRegions[region]) return; // Already subscribed.
62
69
  networkPromise.then(contact => {
63
- this.trackedRegions[region] = true;
70
+ this.trackedRegions[region] = {};
64
71
  const owner = this.tag;
65
72
  ['handle', 'avatar'].forEach(type => {
66
73
  const eventName = this.networkPersistKey(type);
67
- contact.subscribe({eventName, region, owner, since: 'latest', handler: data => this.setPublicData({...data, type})});
74
+ contact.subscribe({eventName, region, owner, since: 'latest', handler: data => this.setPublicData({...data, type, region})});
68
75
  });
69
76
  });
70
77
  }
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
78
+ async setPublicData(data) { // Subscription to public data has fired. Update value, but do not not re-publish (e.g., if our agent).
79
+ let {payload, tag, type, region, topic, ts} = data;
73
80
  // WARNING: IF we chunkify avatars, and we use since:'all', then we need lock out asynchronous
74
81
  // decoding of later timestamps, or of null payloads.
75
82
  // if (payload && (type === 'avatar')) {
@@ -78,7 +85,8 @@ export class Agent {
78
85
  // payload = dataURL;
79
86
  // }
80
87
  this.updateValue(payload, 'public', type, false);
81
- if (tag) this.publicMsgId[type] = tag;
88
+ if (payload) this.trackedRegions[region][type] = tag;
89
+ else delete this.trackedRegions[region][type];
82
90
  }
83
91
 
84
92
  static agents = {}; // tag => Agent
@@ -93,12 +101,12 @@ export class Agent {
93
101
  handle: {system: null, public: null, private: null, mixed: null},
94
102
  avatar: {system: null, public: null, private: null, mixed: null}
95
103
  };
96
- publicMsgId = {}; // maps type => msgId for saved public data of this Agent instance.
97
104
  getValue(scope, type) {
98
105
  return this.values[type][scope];
99
106
  }
100
107
  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;
108
+ const match = this.values[type][scope] === value;
109
+ if (match) return null;
102
110
 
103
111
  // Persist if private. For public, update locally but do not publish until this agent publishes an alert or reply.
104
112
  if (scope === 'private') this.persistPrivate(value, type);
@@ -120,33 +128,41 @@ export class Agent {
120
128
  if (value === null) localStorage.removeItem(key);
121
129
  else localStorage.setItem(key, value);
122
130
  }
123
- async persistPublicMetadata(region) { // Publish handle and avatar.
124
- this.currentRegion = region;
131
+ async persistPublicMetadata() { // Publish handle and avatar.
132
+ // Used by Agent.current when we post an alert or reply.
133
+ // There could already be such a publication active, but republishing ensures that it is as fresh as
134
+ // the alert/reply (e.g., with respect to expirations).
125
135
  await Promise.all(['handle', 'avatar'].map(type => this.persistPublic(this.getValue('public', type) || null, type)));
126
136
  }
127
137
  async persistPublic(value, type) { // Publish (and we will act on subscription).
138
+ // We persist to ALL our tracked regions, so that anyone in those regions will get the latest metadata.
139
+ // Note that we might be tracking regions A and B, while someone else is tracking B and C.
140
+ // The other user will get the new value through B, and that will be THE value displayed on their machine.
141
+ // BUT... if the other user tracks B and gets the new value, and then moves into region C and gets a
142
+ // stale value, they will see the stale value in both regions going forward in that session until we update in that region.
128
143
  const eventName = this.networkPersistKey(type);
129
- const region = this.currentRegion;
130
144
  const owner = this.tag;
131
145
  const contact = await networkPromise;
132
146
 
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.
147
+ for (let region in this.trackedRegions) {
148
+ // First kill previous, if any.
149
+ // Publish doesn't know whether subscribers will be 'since' all or 'latest', so it must retain all unkilled.
150
+ // Thus if we only kill the last one when there is no value,
151
+ // a subscribe since:latest will produce the PREVIOUS value -- the last unkilled one.
137
152
 
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});
153
+ const killTag = this.trackedRegions[region][type];
154
+ if (killTag) await contact.publish({eventName, region, owner, killTag, payload: null});
141
155
 
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});
156
+ if (value) {
157
+ let payload = value;
158
+ // Our downsampling is such that we do not need to chunkify.
159
+ // if (type === 'avatar') {
160
+ // const blob = await P2PWebNetwork.dataURL2blob(value);
161
+ // payload = (await contact.chunkifyBlob({blob, region})).topic;
162
+ // }
163
+ contact.publish({eventName, region, owner, payload});
164
+ }
165
+ }
150
166
  }
151
167
 
152
168
  // We represent handles and avatars by inserting stuff into given elements.
@@ -275,14 +291,16 @@ export class Agent {
275
291
  static current = null;
276
292
  static tag = null;
277
293
  static identity = null;
294
+ // Keep user separate between dht=1 or empty (Axona) vs dht=0 or -1 (no Axona, for testing).
295
+ static usertagKey = `usertag${parseInt(new URL(location).searchParams.get('dht')) < 1 ? '0' : ''}`;
278
296
  static switchUser(tag, identity) { // Set/persist/ensure the current user, return Agent
279
297
  this.tag = tag; // Before the ensure().
280
298
  this.identity = P2PWebNetwork.currentPublishIdentity = identity;
281
- localStorage.setItem('usertag', this.tag);
299
+ localStorage.setItem(this.usertagKey, this.tag);
282
300
  return this.current = this.ensure({tag, identity});
283
301
  }
284
302
  static async initialize() { // Initialize what the agent needs from the about screen
285
- let tag = localStorage.getItem('usertag');
303
+ let tag = localStorage.getItem(this.usertagKey);
286
304
  const persistAs = tag || uuidv4(); // Give it SOMETHING to persistAs.
287
305
  const myIdentity = await P2PWebNetwork.createAuthorIdentity({persistAs});
288
306
  if (tag !== persistAs) { // Fix up persistence by moving it to where it need to go.
@@ -328,4 +346,3 @@ export class Agent {
328
346
  });
329
347
  }
330
348
  }
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.
@@ -126,7 +134,7 @@ export const Hashtags = {
126
134
  ${active === 'pub' ? 'class="pub"' : ''}
127
135
  ${active ? ' selected' : ''}
128
136
  >${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>
137
+ <md-icon-button slot="remove-trailing-icon"><md-icon class="material-icons"></md-icon></md-icon-button>
130
138
  </md-filter-chip>`;
131
139
  },
132
140
 
@@ -234,8 +242,10 @@ export const Hashtags = {
234
242
  li.id = `tag-option-${i}`;
235
243
  li.setAttribute('role', 'option');
236
244
  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
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.
239
249
  event.preventDefault();
240
250
  event.stopPropagation();
241
251
  this.selectValue(item);
@@ -295,7 +305,6 @@ export const Hashtags = {
295
305
  });
296
306
  this.chipset.insertAdjacentHTML("afterbegin", // Chip to add a new hashtag.
297
307
  `<div class="combobox">
298
- <ul class="combobox-listbox hidden" id="knownTagsListbox" role="listbox"></ul>
299
308
  <md-filled-text-field class="newtag"
300
309
  aria-expanded="false"
301
310
  aria-controls="knownTagsListbox"
@@ -307,7 +316,7 @@ export const Hashtags = {
307
316
  // I've tried also supplying a datalist, e.g., to supply the mobile keyboard completions, but
308
317
  // I have not been able to get it to work.
309
318
  const newtag = this.newtag = this.chipset.querySelector('.newtag');
310
- const listbox = this.listbox = this.chipset.querySelector('.combobox-listbox');
319
+ const listbox = this.listbox = document.querySelector('.combobox-listbox');
311
320
  clickTip(newtag, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
312
321
  event.stopPropagation();
313
322
  Alert.closePopup();
@@ -353,8 +362,10 @@ export const Hashtags = {
353
362
  }
354
363
  };
355
364
  newtag.oninput = () => this.renderSelector(newtag.value);
356
- newtag.onblur = () => this.closeSelector();
357
- //newtag.onchange = () => this.acceptTag();
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);
368
+ newtag.onchange = () => this.acceptTag();
358
369
  },
359
370
  remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
360
371
  delete this.hashtags[chip.label];
@@ -364,25 +375,28 @@ export const Hashtags = {
364
375
  toggleChip(chip) { // Switch whether the topic is or is not subscribed.
365
376
  // Now selected => hashtags[label] becomes 'pub' (selected and the publisher) and clear old pub
366
377
  // NOT now selected => hashtags[label] becomes false (through mechanism as follows)
367
- // but if publisher => set alt publisher if possible, else remember as lastActivePublisher
378
+ // but if publisher => set alt publisher if possible, else remember as backupPublisher
368
379
  const label = chip.label;
369
380
 
370
381
  // chip.selected is new state, after clicking.
371
382
  if (chip.selected) return this.setPublish(label); // Become publisher, clearing old publisher.
372
383
 
384
+ // Not selected:
385
+
373
386
  // If we're not publisher, just clear. But don't go through getPublish, as that can have side effects.
374
387
  if (this.hashtags[label] !== 'pub') return this.hashtags[label] = false;
375
388
 
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);
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;
382
399
  }
383
-
384
- // No alternative. Clear it, but remember for use by getPublish.
385
- this.lastRemainingPublisher = label;
386
400
  return this.hashtags[label] = false;
387
401
  },
388
402
  getChip(label) { // Handy for scripting, but not otherwise used in app.
@@ -391,13 +405,18 @@ export const Hashtags = {
391
405
  }
392
406
  return null;
393
407
  },
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;
408
+ setPublish(newTag) { // Make this topic be the one to be used when we next publish an alert.
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;
397
416
  this.hashtags[newTag] = 'pub';
398
417
  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');
418
+ if (chip.label === newTag) chip.classList.add('pub');
419
+ else if (chip.label === oldTag) chip.classList.remove('pub');
401
420
  }
402
421
  return oldTag;
403
422
  }
@@ -406,5 +425,5 @@ globalThis.Hashtags = Hashtags; // for debugging
406
425
 
407
426
  // Populate hashtags data and display.
408
427
  // First the persisted/default data:
409
- 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"}`);
410
429
  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);
@@ -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 } 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', {
@@ -28,20 +28,24 @@ export class P2PWebNetwork {
28
28
  region = this.sessionRegion,
29
29
  bridgeUrl = (globalThis.location && new URL(globalThis.location).searchParams.get('bridge')) ||
30
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')) ||
31
33
  'wss://bridge.axona.net',
32
34
  } = {}) {
33
35
  // Promise a ready-to-use network peer.
34
36
  region = await region;
35
37
 
38
+ const network = new this();
39
+ network.resetStatePromises();
40
+
36
41
  const { peer, nodeIdentity, transport, status, disconnect } = await connect({
37
42
  bridge: bridgeUrl,
38
43
  location: region,
44
+ onDisconnect: network.detached,
39
45
  author: false
40
46
  });
41
-
42
- const network = new this();
43
47
  Object.assign(network, {infoLogger, debugLogger, disconnector: disconnect, transport, nodeIdentity, peer});
44
- network.resetStatePromises();
48
+
45
49
  network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(region.lat, region.lng).toString(16)}.`);
46
50
  peer.onError(error => {
47
51
  network.info(`error: ${error.message || error}`);
@@ -189,7 +193,7 @@ export class P2PWebNetwork {
189
193
  };
190
194
  await this.peer.sub(topic, callback, {since});
191
195
  } else {
192
- this.peer.unsub(topic, {});
196
+ await this.peer.unsub(topic, {});
193
197
  }
194
198
  }
195
199
  static currentPublishIdentity = null;
@@ -217,7 +221,7 @@ export class P2PWebNetwork {
217
221
  static regionCode(lat, lng) { // Answer containing region code.
218
222
  return geoCellId(lat, lng);
219
223
  }
220
- static regionCenter(regionCode) {
224
+ static regionCenter(regionCode) { // {lat, lng} of center of regionCode
221
225
  return geoCellCenter(regionCode);
222
226
  }
223
227
  static delay(ms, result) { // Promise result after ms milliseconds.
@@ -0,0 +1,168 @@
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 (default)
7
+ // dht 0 -> server
8
+ // dht -1 -> in-memory on client only
9
+ const dht = parseInt(globalThis.process ? globalThis.process.env.DHT : new URL(globalThis.location).searchParams.get('dht'));
10
+
11
+ if (dht < 1) {
12
+
13
+ const { v4:uuidv4 } = await import('uuid');
14
+ const { getContainingCells, getPointInCell } = await import('./s2.js');
15
+ const { cellHex } = await import('./versions.js');
16
+ const operator = await import('./pubsub.js');
17
+
18
+ WIRE_VERSION = 'SERVER';
19
+ KERNEL_VERSION = `${WIRE_VERSION}.1.0`;
20
+ createAuthorIdentity = ({
21
+ persistAs, store = {
22
+ get: (key) => globalThis.localStorage.getItem(key),
23
+ set: (key, value) => globalThis.localStorage.setItem(key, value)
24
+ }}) => {
25
+ let tag;
26
+ if (persistAs) {
27
+ tag = store.get(persistAs);
28
+ if (!tag) {
29
+ tag = uuidv4();
30
+ store.set(persistAs, tag);
31
+ } else if (tag.includes('pubkey')) {
32
+ tag = JSON.parse(tag).pubkey; // if it is a real dump, as for alert-bot.
33
+ }
34
+ }
35
+ return {authorId: tag};
36
+ };
37
+ geoCellId = (lat, lng) => {
38
+ const cells = getContainingCells(lat, lng);
39
+ const hex = cellHex(cells[0]);
40
+ const sliced = hex.slice(0, 2);
41
+ return parseInt(sliced, 16); // The worst way to do this.
42
+ };
43
+ geoCellCenter = regionCode => { // Not right. See getPointInCell comments.
44
+ const expanded = regionCode.toString(16).padStart(2, '0').padEnd(16, '0');
45
+ const cellid = BigInt('0x' + expanded);
46
+ const [lat, lng] = getPointInCell(cellid);
47
+ return {lat, lng};
48
+ };
49
+
50
+ bytesToString = u8 => {
51
+ return new TextDecoder().decode(u8);
52
+ };
53
+ stringToBytes = (str) => {
54
+ return new TextEncoder().encode(str);
55
+ };
56
+ const hasBuffer = typeof Buffer !== 'undefined';
57
+ function bytesToB64(u8) {
58
+ if (hasBuffer) return Buffer.from(u8).toString('base64');
59
+ let s = ''; const CH = 0x8000;
60
+ for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH));
61
+ return btoa(s);
62
+ }
63
+ function b64ToBytes(b64) {
64
+ if (hasBuffer) return new Uint8Array(Buffer.from(b64, 'base64'));
65
+ const s = atob(b64); const u8 = new Uint8Array(s.length);
66
+ for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i);
67
+ return u8;
68
+ }
69
+ publishChunkedBytes = (peer, u8, {name, mime}) => {
70
+ const str = bytesToB64(u8);
71
+ return {topic: {name, mime, str}};
72
+ };
73
+ receiveChunkedBytes = (peer, {name, mime, str}, options) => {
74
+ const u8 = b64ToBytes(str);
75
+ return {bytes: u8, name, mime, msgIds: []};
76
+ };
77
+
78
+ connect = async ({bridge, location, onDisconnect}) => {
79
+ // We always call location as {lat, lng}
80
+ // We always call it with author:false
81
+ const {lat, lng} = location;
82
+ const region = geoCellId(lat, lng);
83
+ const nodeTag = region.toString(16).padStart(2, '0') + uuidv4();
84
+ const nodeIdentity = {id: nodeTag};
85
+ const handlers = {}; // guid => handler tag
86
+ const inFlight = {};
87
+ let disconnect, transport; // But do not call P2PWebNetwork.ice!!!
88
+ const send = await new Promise(resolve => {
89
+ if (dht === 0) {
90
+ const url = `${bridge}/${nodeTag}`;
91
+ const socket = transport = new WebSocket(url);
92
+ socket.onmessage = event => {
93
+ const [tag, ...rest] = JSON.parse(event.data);
94
+ const subHandler = handlers[tag];
95
+ const inFlightResolver = inFlight[tag];
96
+ if ((!subHandler && !inFlightResolver) ||
97
+ ((typeof(subHandler) !== 'function') && (typeof(inFlightResolver) !== 'function')))
98
+ console.log({tag, rest, subHandler, inFlightResolver, handlers, inFlight});
99
+ if (subHandler) return subHandler(...rest);
100
+ delete inFlight[tag];
101
+ return inFlightResolver?.(...rest);
102
+ };
103
+ socket.onopen = () => {
104
+ if (socket.readyState !== WebSocket.OPEN) return; // You would think that can't happen, but...
105
+ resolve((...rest) => { // send()
106
+ const tag = uuidv4();
107
+ const {promise, resolve} = Promise.withResolvers();
108
+ inFlight[tag] = resolve;
109
+ socket.send(JSON.stringify([tag, ...rest]));
110
+ return promise;
111
+ });
112
+ };
113
+ // onerror is of no help, as the event is generic.
114
+ socket.onclose = event => {
115
+ console.warn('websocket close', event.code, event.wasClean, event.reason);
116
+ onDisconnect();
117
+ };
118
+ disconnect = () => socket.close();
119
+ } else {
120
+ disconnect = () => null;
121
+ operator.setReceiver((nodeTag, id, ...rest) => handlers[id](...rest));
122
+ resolve((methodName, ...rest) => operator[methodName](...rest)); // send()
123
+ }
124
+ });
125
+
126
+ const peer = {
127
+ onError() {},
128
+ onLog() {},
129
+ health() {
130
+ return {peers: [], axonRoles: []};
131
+ },
132
+ async leave() {
133
+ await send('deleteSubscriber', nodeTag);
134
+ disconnect();
135
+ },
136
+ async sub(topic, handler, options) {
137
+ const result = await send('subscribe', topic, nodeTag, options);
138
+ handlers[result.id] = handler;
139
+ return result;
140
+ },
141
+ async unsub(topic, options) {
142
+ const result = await send('unsubscribe', topic, nodeTag, options);
143
+ delete handlers[result.id];
144
+ return result;
145
+ },
146
+ async pub(topic, message, options) {
147
+ return send('publish', topic, message, options);
148
+ },
149
+ kill(topic, msgId, options) {
150
+ return send('unpublish', topic, msgId, options);
151
+ },
152
+ host() {},
153
+ unhost() {}
154
+ };
155
+ const status = {peers: 0, ms: 0}; // fixme ms
156
+ return { peer, nodeIdentity, transport, status, disconnect };
157
+ };
158
+
159
+ } else {
160
+ const protocol = await import('@axona/protocol');
161
+ const std = await import('@axona/protocol/std');
162
+ ({ connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } = protocol);
163
+ ({ stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } = std);
164
+ }
165
+
166
+ export { connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION };
167
+ export { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes }
168
+
@@ -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.21';
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%);
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();
@@ -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
+ }