@yz-social/civildefense.io 4.5.22 → 4.5.23

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.22",
4
+ "version": "4.5.23",
5
5
  "keywords": [
6
6
  "map",
7
7
  "browser",
@@ -10,6 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "npm stop; node ./server/app.js",
12
12
  "stop": "pkill yz.social",
13
+ "background": "npm stop; node ./server/app.js >server.log &",
13
14
  "postinstall": "touch server/location.json; chmod a+w server/location.json",
14
15
  "prepack": "mv server/location.json server/location.save.json",
15
16
  "postpack": "mv server/location.save.json server/location.json"
@@ -19,7 +20,7 @@
19
20
  ".": "./index.js"
20
21
  },
21
22
  "dependencies": {
22
- "@axona/protocol": "github:axona-net/axona-protocol#semver:^4.59.2",
23
+ "@axona/protocol": "github:axona-net/axona-protocol#semver:^4.62.2",
23
24
  "cors": "^2.8.6",
24
25
  "express": "^4.22.1",
25
26
  "leaflet": "^1.9.4",
@@ -79,7 +79,7 @@
79
79
  <li>Añadir comentarios, fotos, videos u otros archivos adjuntos.</li>
80
80
  <li>Recibir notificaciones de nuevas alertas cerca de ti o en tu mapa actual.</li>
81
81
  <li>Compartir en redes sociales, por mensaje de texto o correo electrónico.</li>
82
- <li>Discutir una alerta mediante videoconferencia segura. (Disponible este otoño.)</li>
82
+ <li>Discutir una alerta mediante videoconferencia segura. (Disponible este invierno.)</li>
83
83
  </ul>
84
84
 
85
85
  <a href="/?lang=es" class="launch-btn" target="civildefense">
@@ -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, cellHex } from './versions.js';
11
- import { getContainingCells, getSubdivision, findCoverCellsByMinMaxLatLng } from './s2.js';
10
+ import { alertTopic, topicRegion, cellHex, topicCell } from './versions.js';
11
+ import { getContainingCells, getSmallestCellId, getSubdivision, findCoverCellsByMinMaxLatLng, cellContains, pointFromLatLng, cellFromCellID } from './s2.js';
12
12
  const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
13
13
 
14
14
 
@@ -63,7 +63,9 @@ const ttl = 24 * 60 * 60e3; // 24 hours
63
63
  let openOnReceive = null;
64
64
  export function go({lat = null, lng = null, zoom = null, alert = null}) { // Go to specified location (if any) and open marker (if any).
65
65
  if (lat !== null && lng !== null) {
66
- if (zoom) map.flyTo({lat, lng}, zoom);
66
+ lat = parseFloat(lat);
67
+ lng = parseFloat(lng);
68
+ if (zoom) map.flyTo({lat, lng}, parseFloat(zoom));
67
69
  else map.flyTo({lat, lng});
68
70
  }
69
71
  openOnReceive = null;
@@ -102,13 +104,291 @@ class AlertReply extends Reply {
102
104
 
103
105
  import { s2 } from 's2js';
104
106
  export class Alert extends Conversation { // A wrapper around L.marker
105
- // When we resubscribe to different cells covering the same place, we will get the same
106
- // sticky data. We don't want to change the marker. Fortunately, the publication to each
107
- // of the cells (at different scales) are all published with the same data.
107
+ // For each hashtag, we subscribe to a set of non-overlapping cells at varying S2 levels that cover the current map.
108
+ // An Alert is made when a subscription handler fires, and it keeps information that is (mostly) true regardless of
109
+ // which cell brought it in. E.g., each alert has a bookkeeping tag that identifies that map-click, which is
110
+ // the same at all S2 levels, and forms the topic for replies to that alert. When the map changes and the subscriptions
111
+ // are updated, we try to re-use the same Alerts so that the markers don't flash and we don't create garbage.
112
+
113
+ // INSTANCE MANAGEMENT
114
+ // When there are "too many" instances in a cell (at any level), we replace the individual Alerts with a single larger aggregate.
115
+ // This allows us to handle any any quantity of Alerts in memory and visual clutter, and to not mislead users in the presence
116
+ // of publication topic "rollover". However, it greatly complicates insance management.
117
+
118
+ // Conversation.ensure is the subscription handler, and it keeps track of the instances by tag, initializing a new one if needed.
119
+ initialize({topic, payload, hashtag, tag, agent, issuedTime, ...rest}) { // Make appropriate instance for a new individual tag, or update aggregate.
120
+
121
+ if (!Hashtags.isSubscribed(hashtag)) return null; // A subscribed event may have been in flight while unsubscribing. Caller destroys instance.
122
+ const now = Date.now(),
123
+ expiration = issuedTime + ttl,
124
+ remaining = expiration - now;
125
+ if (remaining < 0) return null; // Network shouldn't send us expired, but if it does, let its instance be destroyed.
126
+
127
+ const eventName = topic.name;
128
+ let keep = this; // Instance to be kept as marker.
129
+ let aggregate = this.constructor.getAggregate(eventName); // If we already have one for this eventName
130
+
131
+ // Each new initialization gets counted, which may be more than the network rollover.
132
+ // We do not "back up" for deletions and expirations - i.e., subtract and possibly go back to individual alerts.
133
+ // Note that initialize() will not be called for an event handler that has a tag (msgId) the same as one we have already seen for a different cell scale.
134
+ this.constructor.subscriptions[eventName]++;
135
+
136
+ if (aggregate) {
137
+ keep = null; // This new instance is superfluous. Tell ensure() to destroy it...
138
+ aggregate.lerp(eventName, payload.lat, payload.lng); // ...but nudge the existing aggregate towards us.
139
+ } else { // Does not exist yet
140
+ let {lat, lng, originalPosting} = payload;
141
+ lat = parseFloat(lat);
142
+ lng = parseFloat(lng);
143
+ if (this.constructor.cellCountOverLimit(eventName)) aggregate = this; // If now over, treat this marker as an aggregate.
144
+ const icon = this.constructor.makeIcon(hashtag, tag, aggregate);
145
+ const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
146
+ const region = P2PWebNetwork.regionCode(lat, lng);
147
+ hashtag = Hashtags.add(hashtag); // We already have it and are subscribing, but this updates our extended form if needed.
148
+ super.initialize({payload, hashtag, tag, agent, lat, lng, issuedTime, originalPosting, ...rest});
149
+ if (aggregate) {
150
+ // Destroy existing eventName markers and add their positions to the aggregate we are creating.
151
+ this.becomeAggregate(eventName);
152
+ this.constructor.clearEventMarkers(eventName, aggregate);
153
+ } else {
154
+ this.noteEventName(eventName);
155
+ marker.bindPopup('', {className: 'alert'}).on('popupopen', event => this.ensureContent(event.popup));
156
+ tooltip(marker.getElement(), Int`Show conversation for this ${hashtag} alert.`);
157
+ if (tag === openOnReceive) { // Bug! How can we handle URLs to an alert that has been aggregated?
158
+ openOnReceive = false;
159
+ this.openPopup();
160
+ }
161
+ networkPromise.then(async contact => { // Subscribe to replies to this tag, now that we have an alert for them to go to.
162
+ contact.subscribe({eventName: tag, region, handler: data => this.ensure(data)});
163
+ });
164
+ }
165
+ }
166
+ const alert = aggregate || this;
167
+ alert.startExpiration('.alert-pin', remaining);
168
+ alert.showNotification({agent, issuedTime});
169
+ return keep;
170
+ }
171
+ update({topic, ts, ...rest}) { // Called when handling an existing Conversation. super confirms that nothing immutable has changed.
172
+ return super.update({...rest}); // topic and ts vary with level, and so must not be part of ensure/update checks.
173
+ }
174
+ async destroy(markerDelayMS = 400) { // Remove this Alert pin entirely, either through unpublish, expiration, or conversion of a cell to aggregate.
175
+ // We do not decrement Alert.subscriptions[this.eventName] and unaggregate into individual markers.
176
+ // That won't happen until the user completely unsubscribes from this cell and resubscribes (by toggle or map movement).
177
+ clearInterval(this['.alert-pin']);
178
+ clearInterval(this['.alert-commented']);
179
+ clearInterval(this.destroyer);
180
+ this.clearAvatars();
181
+ const {isAggregate, marker, tag, region} = this;
182
+ // Unsubscribe from replies.
183
+ if (!isAggregate) networkPromise?.then(async contact => contact.subscribe({eventName: tag, region, handler: null}));
184
+ this.cellBorder?.removeFrom(map);
185
+ super.destroy();
186
+ if (markerDelayMS) {
187
+ marker.closePopup();
188
+ marker.unbindPopup(); // It would be confusing if it happens to be open, or clicked on while being removed.
189
+ marker.setOpacity(0);
190
+ await P2PWebNetwork.delay(markerDelayMS);
191
+ }
192
+ marker.removeFrom(map);
193
+ }
194
+
195
+ static subscriptionQueue = Promise.resolve(); // Serialize updates so they don't overlap each other.
196
+ static async updateSubscriptions({newKeys, oldKeys, throttleMS = 20} = {}) { // Update current subscriptions.
197
+ // A value of {} passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
198
+ return this.subscriptionQueue = this.subscriptionQueue.then(async () => {
199
+ oldKeys ||= this.subscriptions;
200
+ newKeys ||= this.subscriptionFromMap();
201
+
202
+ if (!newKeys) return; // e.g., wacky computation. Don't change anything.
203
+ const contact = await networkPromise;
204
+ const dropped = [], added = [];
205
+ if (!contact) { console.warn("No network through which to subscribe."); return; } // Does this ever happen? Why?
206
+ this.subscriptions = newKeys; // Before subscribing.
207
+ const subscribe = async (eventName, handler) => {
208
+ if (!eventName) console.log('sub to no eventName', {oldKeys, newKeys, dropped, added, handler});
209
+ const region = topicRegion(eventName);
210
+ if (handler) Agent.current?.trackPublicChanges(region); // Background. No need to await.
211
+ await contact.subscribe({eventName, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
212
+ };
213
+ for (const key in newKeys) oldKeys.hasOwnProperty(key) || added.push(key);
214
+ for (const key in oldKeys) newKeys.hasOwnProperty(key) || dropped.push(key);
215
+ console.log('updating subscriptions', {added, dropped, newKeys, oldKeys});
216
+
217
+ // Before subscribing, as that that may bring in an alert with the same tag as one being cleared.
218
+ if (this.aggregateLimit) this.transferOrClearEventMarkers(added, dropped, newKeys, oldKeys);
219
+
220
+ for (const key of added) await subscribe(key, data => Alert.ensure(data));
221
+ for (const key of dropped) await subscribe(key, null);
222
+
223
+ // for (const alert of this.items) { // fixme remove
224
+ // if (this.subscriptions[alert.eventName] == undefined) {
225
+ // let kind = added.includes(alert.eventName) && 'added';
226
+ // kind ||= dropped.includes(alert.eventName) && 'dropped';
227
+ // kind ||= Object.keys(newKeys).includes(alert.eventName) && 'new';
228
+ // kind ||= Object.keys(oldKeys).includes(alert.eventName) && 'old';
229
+ // throw new Error(`${kind} ${alert.eventName} has no count after subscription update.`);
230
+ // }
231
+ // }
232
+ });
233
+ }
234
+
235
+ // Instance Management Internals
236
+ // In general here, a key is an eventName - i.e., a string <mumble>:<cellID>:<hashtag>.
237
+ // newKeys/oldKeys are a map of the currently subscribed eventName and the count of events for that cell+hashtag
108
238
  static subscriptions = {}; // maps currently active eventNames (<mumble>:<cellID>:<hashtag>) to count of event received for it.
239
+ static aggregateLimit = parseInt(new URLSearchParams(location.search).get('max') || 100); // How many individuals are allowed before we aggregate.
240
+ static cellCountOverLimit(eventName, countsDictionary = this.subscriptions) { // Have we received enough that we must show an aggregate?
241
+ return countsDictionary[eventName] >= this.aggregateLimit;
242
+ }
243
+ static getAggregate(eventName) { // Answer the existing aggregate for this cell, if any.
244
+ return this.getItem(eventName); // While individual Alerts are stored in items/conversations by tag, the tag for aggregate is the eventName.
245
+ }
246
+ logAlert(label = '') { // For debugging, when an individual or aggregate marker is clicked, show the alert, cell, and count in console.
247
+ const {lat, lng, eventName} = this;
248
+ console.warn(`${label} lat: ${lat}, lng: ${lng}, ${eventName}: ${this.constructor.subscriptions[eventName]}`);
249
+ }
250
+ noteEventName(eventName) { // Be a part of the specified grouping.
251
+ if (this.eventName === eventName) return;
252
+ this.eventName = eventName;
253
+ if (!this.isAggregate) return;
254
+ this.cellBorder?.removeFrom(map);
255
+ const cell = cellFromCellID(topicCell(eventName));
256
+ const radiansToDegrees = 180 / Math.PI;
257
+ const corners = Array.from({ length: 4 }, (_, i) => {
258
+ const point = cell.vertex(i);
259
+ const latLng = s2.LatLng.fromPoint(point);
260
+ return [latLng.lat * radiansToDegrees, latLng.lng * radiansToDegrees];
261
+ });
262
+ const border = this.cellBorder = L.polygon(corners, {color: 'red'});
263
+ border.addTo(map);
264
+ }
265
+ static forEachAlertOf(eventName, callback, alerts = this.items) { // Apply callback to each of alerts matching eventName.
266
+ // TODO? Record a cell's Alert's more efficiently, so that we don't have to cycle through everything.
267
+ alerts.forEach((alert, index, alerts) => (alert.eventName === eventName) && callback(alert, index, alerts));
268
+ }
269
+ static clearEventMarkers(eventName, aggregate = null, alerts = this.items) { // destroy each, but
270
+ // if aggregate is specified, keep the aggregate and average all position into the aggregate.
271
+ if (!aggregate) return this.forEachAlertOf(eventName, alert => alert.destroy(), alerts);
272
+ const eachExceptAggregate = cb => this.forEachAlertOf(eventName, alert => (alert === aggregate) || cb(alert), alerts);
273
+ let lat = aggregate.lat, lng = aggregate.lng, count = 1;
274
+ if (aggregate.eventName != eventName) { // Loading from another eventName. Keep existing weight.
275
+ count = this.subscriptions[aggregate.eventName];
276
+ lat *= count;
277
+ lng *= count;
278
+ }
279
+ eachExceptAggregate(alert => { lat += alert.lat; lng += alert.lng; count++; });
280
+ lat /= count;
281
+ lng /= count;
282
+ return this.forEachAlertOf(eventName, alert => {
283
+ alert.reposition(lat, lng);
284
+ if (alert !== aggregate) alert.destroy(400); // May or may not be default. Half the translation transition time.
285
+ }, alerts);
286
+ }
287
+ becomeAggregate(eventName) { // Make an individual alert be an aggregate
288
+ const {tag, marker, hashtag, region} = this;
289
+ const element = marker.getElement();
290
+ const pin = element.querySelector('.alert-pin');
291
+ this.isAggregate = true;
292
+ pin.classList.toggle('aggregate', true); pin.classList.toggle('starting', true);
293
+ setTimeout(() => pin.classList.toggle('starting', false), 100);
294
+ marker.unbindPopup();
295
+ marker.off('click');
296
+ marker.on('click', event => this.logAlert('FIXME go down one level'));
297
+ tooltip(element, Int`Zoom in on multiple ${hashtag} alerts.`); // fixme Int.
298
+ this.noteEventName(this.tag = eventName);
299
+ if (tag !== eventName) {
300
+ networkPromise.then(async contact => contact.subscribe({eventName: tag, region, handler: null})); // Unsubscribe from replies.
301
+ this.items = [];
302
+ clearInterval(this['.alert-commented']);
303
+ element.querySelector('.alert-commented').style = "opacity: 0;";
304
+ this.constructor.removeItem(tag);
305
+ }
306
+ this.constructor.setItem(eventName, this);
307
+ }
308
+ static handleAggregation(droppedEventName, addedEventName, newCounts, alerts) { // Return aggregate if over limit, setting it up if necessary. Else null.
309
+ // If over limit, converts an addedEventName Alert if needed and clears the rest, and then clears all droppedEventName Alerts.
310
+ if (!this.cellCountOverLimit(addedEventName, newCounts)) return null;
311
+ let aggregate = this.getAggregate(addedEventName);
312
+ if (!aggregate) { // If no existing aggregate:
313
+ aggregate = alerts.find(alert => alert.eventName === droppedEventName); // Pick a dropped individual to become aggregate. There will be one, by construction.
314
+ aggregate.becomeAggregate(addedEventName);
315
+ this.clearEventMarkers(addedEventName, aggregate, alerts); // Kill the rest, adding their positions to the aggregate.
316
+ }
317
+ return aggregate;
318
+ }
319
+ static transferOrClearEventMarkers(added, dropped, newCounts, oldCounts) { // Clear dropped alerts as necessary,
320
+ // but try to preserve (individual and aggregated) alerts so that the markers don't flash.
321
+ const addedCells = added.map(topicCell); // List of BigInt, in same order as added eventNames.
322
+ const alerts = this.items;
323
+ dropped.sort((a, b) => oldCounts[b] - oldCounts[a]); // So that we always process aggregates before related individuals.
324
+ dropped.forEach(droppedEventName => {
325
+ const droppedCell = topicCell(droppedEventName);
326
+ // Whether zooming in or out, each dropped cell may have added cells that contain it, or vice versa.
327
+ // We don't have to worry about cells that are neither added nor dropped, because those will not overlap added or dropped.
328
+
329
+ // Find the one added cell (if any) that contains the dropped cell. (Typically when zooming out.)
330
+ const containerIndex = addedCells.findIndex(added => cellContains(added, droppedCell));
331
+ if (containerIndex >= 0) {
332
+ const addedContainingEventName = added[containerIndex];
333
+ newCounts[addedContainingEventName] += oldCounts[droppedEventName]; // It all goes to the container, which may have already started filling.
334
+ const aggregate = this.handleAggregation(droppedEventName, addedContainingEventName, newCounts, alerts);
335
+ if (aggregate) {
336
+ this.clearEventMarkers(droppedEventName, aggregate, alerts); // Absorb the discarded. Their distinctiveness will be added to our own.
337
+ return;
338
+ }
339
+ this.forEachAlertOf(droppedEventName, alert => alert.noteEventName(addedContainingEventName)); // Label the dropped individual alerts for new container.
340
+ return;
341
+ }
342
+
343
+ const aggregate = this.getAggregate(droppedEventName);
344
+ if (aggregate) { // We don't know how mucch of this will be included in any added subregion, and cannot reconstruct where they were.
345
+ aggregate.destroy();
346
+ return;
347
+ }
348
+
349
+ // Find the 0-4 added cells that are contained by the dropped cell. (Typically when zooming in.)
350
+ const addedSubCells = addedCells.filter(added => cellContains(droppedCell, added));
351
+ // We don't know how many oldCounts to distribute to each subcell, so we have to work with each dropped alert's location.
352
+ if (addedSubCells.length) {
353
+ const addedS2Cells = addedSubCells.map(cellFromCellID);
354
+ this.forEachAlertOf(droppedEventName, alert => {
355
+ const point = pointFromLatLng(alert.lat, alert.lng);
356
+ const addedS2Cell = addedS2Cells.find(cell => cell.containsPoint(point));
357
+ if (addedS2Cell) {
358
+ const includedIndex = addedCells.indexOf(addedS2Cell.id);
359
+ const addedIncludedEventName = added[includedIndex];
360
+ newCounts[addedIncludedEventName] += 1;
361
+ if (this.handleAggregation(droppedEventName, addedIncludedEventName, newCounts, alerts)) return;
362
+ alert.noteEventName(addedIncludedEventName); // Leave this alert in place, but label where it belongs.
363
+ } else {
364
+ alert.destroy(); // This alert is not within an added subcell.
365
+ }
366
+ }, alerts);
367
+ return;
368
+ }
369
+
370
+ // Otherwise, no overlap, so clear the (individual and aggregate) markers of the dropped cell.
371
+ this.clearEventMarkers(droppedEventName);
372
+ });
373
+ }
374
+ lerp(eventName, additionaLatitude, additionalLongitude) { // Move this Alert towards this location, inversely weight by count in cell.
375
+ const count = this.constructor.subscriptions[eventName];
376
+ const k = 1 / count;
377
+ const k1 = 1 - k;
378
+ const {lat, lng} = this;
379
+ this.reposition(k1 * lat + k * additionaLatitude,
380
+ k1 * lng + k * additionalLongitude);
381
+ }
382
+ reposition(lat, lng) { // Update the marker's position for new data.
383
+ this.lat = lat;
384
+ this.lng = lng;
385
+ this.marker.setLatLng([lat, lng]);
386
+ this.startExpiration('.alert-pin', Date.now() + ttl);
387
+ }
388
+
109
389
  // We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
110
390
  static lastLevel9Cell = null; // S2 level 9 cells average a radius of about 10km ~ 6.5 miles.
111
- static subscriptionFromMap() { // Generate {eventName => count} for current map bounds.
391
+ static subscriptionFromMap() { // Generate new subscriptions list ({eventName => count}) for current map bounds.
112
392
  const center = map.getCenter();
113
393
  const bounds = map.getBounds();
114
394
  const northEast = bounds.getNorthEast();
@@ -123,84 +403,47 @@ export class Alert extends Conversation { // A wrapper around L.marker
123
403
  });
124
404
  if (!newCells) return null;
125
405
  const newKeys = {};
126
-
127
- newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => {
406
+ newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => { // Populate count with existing count (exctly carried over cell sizes), else 0.
128
407
  const eventName = alertTopic(cell, hash);
129
408
  newKeys[eventName] = this.subscriptions[eventName] || 0;
130
409
  }));
131
410
  // Record a zoomed-out cell id in case next session does not have geolocation services.
132
- let level9Cell = getContainingCells(center.lat, center.lng)[9];
411
+ let level9Cell = getSmallestCellId(center.lat, center.lng, 9);
133
412
  if (level9Cell !== this.lastLevel9Cell) localStorage.setItem('level9Cell', this.lastLevel9Cell = level9Cell);
134
413
  return newKeys;
135
414
  }
136
- static async updateSubscriptions({
137
- oldKeys = this.subscriptions,
138
- newKeys = this.subscriptionFromMap(),
139
- throttleMS = 20
140
- } = {}) { // Update current subscriptions.
141
- // A value of {} passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
142
- if (!newKeys) return; // e.g., wacky computation. Don't change anything.
143
- const contact = await networkPromise;
144
- if (!contact) { console.warn("No network through which to subscribe."); return; } // Does this ever happen? Why?
145
- this.subscriptions = newKeys; // Before subscribing.
146
- const subscribe = async (key, handlerIn) => {
147
- const handler = handlerIn && (properties => {
148
- // When there are enough alerts within a topic, it rolls over and just reports the most recent number.
149
- // When get near that count, we update the subscriptions such that the overloading topic is replaced
150
- // with the topics for each of the four subcells that that make up the one with too many alerts.
151
- // This repeats until we reach cells that are not rolling over.
152
- const maxAlertsInCell = 900;
153
- let count = ++newKeys[key];
154
- handlerIn(properties);
155
- if (count >= maxAlertsInCell) {
156
- let nextKeys = {};
157
- for (const old in newKeys) {
158
- if (old !== key) {
159
- nextKeys[old] = newKeys[old];
160
- } else {
161
- const cellTag = topicCell(key);
162
- const subdivisions = getSubdivision(cellTag);
163
- const topics = subdivisions.map(cellTag => alertTopic(cellTag, properties.hashtag));
164
- console.log('subdividing', key, 'into', topics);
165
- topics.forEach(topic => nextKeys[topic] = 0);
166
- }
167
- }
168
- this.updateSubscriptions({oldKeys: newKeys, newKeys: nextKeys, throttleMS});
169
- }
170
- });
171
- const region = topicRegion(key);
172
- Agent.current?.trackPublicChanges(region); // Background. No need to await.
173
- await contact.subscribe({eventName: key, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
174
- };
175
- console.log('updating subscriptions', {newKeys, oldKeys});
176
- // For each entry in the new subscription set that was not previously subscribed, subscribe now.
177
- for (const key in newKeys) oldKeys.hasOwnProperty(key) || await subscribe(key, data => Alert.ensure(data));
178
- // For each existing subscription, if it does not appear in the new set then unsubscribe.
179
- for (const key in oldKeys) newKeys.hasOwnProperty(key) || await subscribe(key, null);
180
- }
415
+
416
+ // PUBLISHING NEW ALERTS
417
+ // Each map click publishes to that point at each supported S2 level that contains it, so that any map display of
418
+ // non-overlapping cells will catch it. (Network subscriptions are much more expensive than publishing, and the app
419
+ // users watch more than they publish, so we minimize the number of subscriptions that a user has at any moment.)
420
+
421
+ // The app (not the network) restricts the user to publish up to maxPublish alerts over the last maxPublish minutes.
422
+ // I.e., one / minute, but allowing bursts up to maxPublish. After that, one can still publish, but kill the oldest.
423
+ // We keep enough data in memory that we can reproduce what to kill,m even if Alert has since scrolled off the map and been destroyed.
181
424
  static maxPublish = 5;
182
425
  static publishing = false;
183
- static lastPublished = []; // Last published lat, lng, tag
426
+ static lastPublished = []; // Last published lat, lng, hashtag
184
427
  // Publish an alert to all applicable eventNames, canceling as required. Promises tag (msgId).
185
428
  static async publish({lat, lng,
186
429
  originalPosting = undefined,
187
430
  hashtag = Hashtags.getPublish(true),
188
- payload = {lat, lng, originalPosting}, // If payload is null (cancels subject), lat & lng are still used to generate eventNames.
431
+ payload = {lat, lng, originalPosting}, // If payload is null (cancels tag), lat & lng are still used to generate eventNames.
189
432
  cancel = undefined, // First unpublish the specified data, if any. Complicated default.
190
- issuedTime = Date.now(), subject,
433
+ issuedTime = Date.now(), tag,
191
434
  throttleMS = 0,
192
435
  ...rest
193
436
  }) {
194
- // We call all the publishing at once and return subject, without waiting for each to occur.
437
+ // We call all the publishing at once and return tag, without waiting for each to occur.
195
438
  // However, the 'unpublishing' (if any) is invoked first.
196
439
  // To do this, we must hash the eventName ourselves.
197
- //console.log('publish', {lat, lng, hashtag, payload, cancel, subject, issuedTime, rest});
440
+ //console.log('publish', {lat, lng, hashtag, payload, cancel, tag, issuedTime, rest});
198
441
  if (this.publishing) { console.log('skiping overlapping publish'); return null; } // do not stack them up.
199
442
  try {
200
443
  this.publishing = true;
201
444
 
202
445
  const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
203
- let oldCells = null, oldHash, oldSubject = null; // Recorded for logging, below.
446
+ let oldCells = null, oldHash, oldTag = null; // Recorded for logging, below.
204
447
  let lastFillIn;
205
448
  if (payload) {
206
449
  lastFillIn = {lat, lng, hashtag, issuedTime};
@@ -213,14 +456,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
213
456
  }
214
457
  }
215
458
  if (cancel) {
216
- const {lat, lng, hashtag, subject} = cancel;
459
+ const {lat, lng, hashtag, tag} = cancel;
217
460
  oldCells = getContainingCells(lat, lng);
218
- oldHash = hashtag; oldSubject = subject;
461
+ oldHash = hashtag; oldTag = tag;
219
462
  const region = P2PWebNetwork.regionCode(lat, lng);
220
463
  for (const cell of oldCells) {
221
464
  const eventName = alertTopic(cell, hashtag);
222
465
  // Note: we cannot unpublish replies by others, but they expire after a while anyway.
223
- await contact.publish({eventName, region, killTag: subject, payload: null});
466
+ await contact.publish({eventName, region, killTag: tag, payload: null});
224
467
  throttleMS && await P2PWebNetwork.delay(throttleMS);
225
468
  }
226
469
  }
@@ -234,23 +477,23 @@ export class Alert extends Conversation { // A wrapper around L.marker
234
477
  // and when combined with the publisher's authorId will be unique to this user/time/hashtag,
235
478
  // and yet the same for each of the individual publications at the different s2 scales.
236
479
  const msgId = await contact.publish({eventName, region, payload, issuedTime, hashtag, ...rest});
237
- if (subject && subject !== msgId) throw new Error(`msgId is drifting: ${subject} => ${msgId}`);
238
- subject = msgId;
480
+ if (tag && tag !== msgId) throw new Error(`msgId is drifting: ${tag} => ${msgId}`);
481
+ tag = msgId;
239
482
  if (lastFillIn) {
240
- lastFillIn.subject = subject;
483
+ lastFillIn.tag = tag;
241
484
  lastFillIn = null;
242
485
  }
243
486
  } else {
244
- await contact.publish({eventName, region, killTag: subject, payload: null});
487
+ await contact.publish({eventName, region, killTag: tag, payload: null});
245
488
  throttleMS && await P2PWebNetwork.delay(throttleMS);
246
489
  }
247
490
  }
248
491
  if (!payload) {
249
- const index = this.lastPublished.findIndex(past => past.subject === subject);
492
+ const index = this.lastPublished.findIndex(past => past.tag === tag);
250
493
  if (index >= 0) this.lastPublished.splice(index, 1);
251
494
  }
252
- console.log('Published', {cells, n: cells.length, region, hashtag, subject, payload, oldCells, oldHash, oldSubject});
253
- return subject;
495
+ console.log('Published', {cells, n: cells.length, region, hashtag, tag, payload, oldCells, oldHash, oldTag});
496
+ return tag;
254
497
  } finally {
255
498
  this.publishing = false;
256
499
  }
@@ -261,7 +504,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
261
504
  map.closePopup();
262
505
  Hashtags.closeSelector();
263
506
  }
264
- static openPopup(alertTag) { // Open the marker specified by subject.
507
+ static openPopup(alertTag) { // Open the marker specified by tag.
265
508
  const wrapper = this.getItem(alertTag);
266
509
  wrapper?.openPopup() || (openOnReceive = alertTag);
267
510
  }
@@ -274,9 +517,10 @@ export class Alert extends Conversation { // A wrapper around L.marker
274
517
  }
275
518
  this.marker.openPopup();
276
519
  }
277
- static makeIcon(hashtag) { // Return a Leaflet icon. TODO: are these cacheable and reusable?
520
+ static makeIcon(hashtag, tag, isAggregate = false) { // Return a Leaflet icon.
521
+ // tag is handy for debugging. TODO: are these cacheable and reusable?
278
522
  return L.divIcon({
279
- html: `<div class="alert-commented"></div><div class="alert-pin">${Hashtags.formatAlert(hashtag)}</div>`,
523
+ html: `<div class="alert-commented"></div><div class="alert-pin${isAggregate ? ' aggregate' : ''}" data-debug="${tag}">${Hashtags.formatAlert(hashtag)}</div>`,
280
524
  iconSize: [40, 40],
281
525
  popupAnchor: [0, 0],
282
526
  className: 'alert-marker'
@@ -286,12 +530,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
286
530
  this.items.forEach(wrapper => {
287
531
  const { hashtag, marker, agent } = wrapper;
288
532
  if (hashtag !== canonicalHashtag) return;
289
- const newIcon = this.makeIcon(extendedHashtag);
290
- const popup = marker.getPopup();
533
+ const newIcon = this.makeIcon(extendedHashtag, wrapper.tag);
291
534
  marker.setIcon(newIcon);
292
535
  wrapper.hashtag = extendedHashtag;
293
536
  wrapper.needsRedisplay = true; // See comment for initializeHandlers. We need to clear and rebuild content on re-open.
294
- if (!popup.isOpen()) return;
537
+ const popup = marker.getPopup();
538
+ if (!popup?.isOpen()) return; // Either an aggregate or closed.
295
539
  // Fix what's showing now without flashing everything. Make sure menu works.
296
540
  const popupAttribution = popup.getElement().querySelector('.attribution');
297
541
  const attributionActions = popupAttribution.lastElementChild;
@@ -300,45 +544,11 @@ export class Alert extends Conversation { // A wrapper around L.marker
300
544
  wrapper.initChangeHashtag(popupAttribution);
301
545
  });
302
546
  }
303
- static async ensure({tag, topic, ts, issuedTime, ...rest}) { // Add marker at position with appropriate fade if not already present.
304
- const alert = await super.ensure({tag, subject: tag, issuedTime, ...rest}); // Does not include topic or ts. fixme: get rid of subject. fixme: why is ts different?
305
- if (!alert) return null;
306
- // Regardless of initialize vs update, reset fader.
307
- const now = Date.now(),
308
- expiration = issuedTime + ttl,
309
- remaining = expiration - now;
310
- if (remaining < 0) return alert?.destroy(); // Expired.
311
- alert.startFader('.alert-pin', remaining); // From the new value of remaining, after marker is set in wrapper, regardless of popup/dirty state.
312
- alert.destroyer = setTimeout(() => alert.destroy(), remaining);
313
- return alert;
314
- }
315
- initialize({payload, hashtag, subject, agent, issuedTime, ...rest}) { // Set up the marker for a newly received alert.
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.
318
- const icon = this.constructor.makeIcon(hashtag);
319
- const {lat, lng, originalPosting} = payload;
320
- const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
321
- const region = P2PWebNetwork.regionCode(lat, lng);
322
- hashtag = Hashtags.add(hashtag); // We already have it and are subscribing, but this updates our extended form if needed.
323
- super.initialize({payload, hashtag, subject, agent, lat, lng, issuedTime, originalPosting, ...rest});
324
-
325
- marker.bindPopup('', {className: 'alert'}).on('popupopen', event => this.ensureContent(event.popup));
326
- tooltip(marker.getElement(), Int`Show conversation for this ${hashtag} alert.`);
327
- if (subject === openOnReceive) {
328
- openOnReceive = false;
329
- this.openPopup();
330
- }
331
- // Subscribe to replies to this subject, now that we're set up to receive them.
332
- networkPromise.then(async contact => {
333
- contact.subscribe({eventName: subject, region, handler: data => this.ensure(data)});
334
- });
335
- this.showNotification({agent, issuedTime});
336
- return this;
337
- }
338
547
 
339
548
  needsRedisplay = true;
340
549
  ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
341
550
  if (!popup.isOpen()) return;
551
+ this.logAlert();
342
552
  if (!this.needsRedisplay) {
343
553
  this.initializeHandlers(popup);
344
554
  return;
@@ -353,7 +563,6 @@ export class Alert extends Conversation { // A wrapper around L.marker
353
563
  this.marker.getPopup().update();
354
564
  this.initializeHandlers(popup);
355
565
  });
356
- console.warn(`latitude: ${this.lat}, longitude: ${this.lng}`);
357
566
  }
358
567
  clearAvatars(popup = this.marker?.getPopup()) {
359
568
  popup?.getElement()?.querySelectorAll('.correspondent[data-tag]')
@@ -422,7 +631,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
422
631
  menu.onclick = consume; // Must be onlick rather than addEventListener.
423
632
  const handler = event => {
424
633
  menu.removeEventListener('close-menu', handler);
425
- this.updatePost(event.detail.initiator.dataset.tag);
634
+ this.updatePost(event.detail.initiator.dataset.tag); // initiator is a hashtag menu item and dataset.tag is a hashtag.
426
635
  };
427
636
  menu.addEventListener('close-menu', handler); // Must be addEventListener because there's no onclosemenu.
428
637
  });
@@ -467,16 +676,16 @@ export class Alert extends Conversation { // A wrapper around L.marker
467
676
  ${actions}
468
677
  </div>`;
469
678
  }
470
- updatePost(newTag) { // Republish under a different hashtag, or cancel altogether if no newTag (which is not allowed as a hashtag).
679
+ updatePost(newHashtag) { // Republish under a different hashtag, or cancel altogether if no newHashtag (which is not allowed as a hashtag).
471
680
  resetInactivityTimer();
472
- const {lat, lng, hashtag, subject, issuedTime, originalPosting = issuedTime} = this;
473
- console.log("updatePost", {newTag, lat, lng, hashtag, subject, issuedTime, originalPosting, self:this});
474
- if (!newTag) return Alert.publish({lat, lng, subject, originalPosting, hashtag, payload: null, cancel: null}); // Remove post with null payload, cancel.
475
- if (newTag === hashtag) return this.needsRedisplay = true;
476
- const cancel = {lat, lng, subject, hashtag}; // Cancel old hashtag as we publish newTag, below.
477
- Hashtags.setPublish(newTag);
681
+ const {lat, lng, hashtag, tag, issuedTime, originalPosting = issuedTime} = this;
682
+ console.log("updatePost", {newHashtag, lat, lng, hashtag, tag, issuedTime, originalPosting, self:this});
683
+ if (!newHashtag) return Alert.publish({lat, lng, tag, originalPosting, hashtag, payload: null, cancel: null}); // Remove post with null payload, cancel.
684
+ if (newHashtag === hashtag) return this.needsRedisplay = true;
685
+ const cancel = {lat, lng, tag, hashtag}; // Cancel old hashtag as we publish newHashtag, below.
686
+ Hashtags.setPublish(newHashtag);
478
687
  Hashtags.onchange({redisplaySubscribers: false, resetSubscriptions: false});
479
- return Alert.publish({lat, lng, hashtag: newTag, originalPosting, cancel}); // Publish new alert w/cancellation.
688
+ return Alert.publish({lat, lng, hashtag: newHashtag, originalPosting, cancel}); // Publish new alert w/cancellation.
480
689
  }
481
690
 
482
691
  // Each reply is separately published by its author, and only they can modify/unpublish it.
@@ -484,7 +693,6 @@ export class Alert extends Conversation { // A wrapper around L.marker
484
693
  return AlertReply;
485
694
  }
486
695
  async ensure(data) { // Add or update reply for this reply.
487
- data.subject = data.tag; //fixme
488
696
  const remaining = data.issuedTime + ttl - Date.now();
489
697
  if (remaining < 0) return null;
490
698
  const reply = await super.ensure(data);
@@ -507,13 +715,13 @@ export class Alert extends Conversation { // A wrapper around L.marker
507
715
  this.ensureContent();
508
716
  return reply;
509
717
  }
510
- async postReply(event) { // Post a reply to this marker's subject, in response to a text-field change event.
718
+ async postReply(event) { // Post a reply to this marker's tag, in response to a text-field change event.
511
719
  resetInactivityTimer();
512
720
  event.stopPropagation();
513
721
  const button = event.target;
514
722
  const inputElement = button.parentElement;
515
723
  let payload = inputElement.value.trim();
516
- const {subject, hashtag, lat, lng} = this;
724
+ const {tag, hashtag, lat, lng} = this;
517
725
  const region = P2PWebNetwork.regionCode(lat, lng);
518
726
  const files = inputElement.parentElement.querySelector('input[type="file"]').files;
519
727
  if (!payload && !files.length) return;
@@ -524,17 +732,17 @@ export class Alert extends Conversation { // A wrapper around L.marker
524
732
  const {topic:file, msgIds} = await contact.chunkifyBlob({blob: files[0], region});
525
733
  payload = {message: payload, file};
526
734
  }
527
- await contact.publish({eventName: subject, region, payload}); // Publish the new reply.
735
+ await contact.publish({eventName: tag, region, payload}); // Publish the new reply.
528
736
  Agent.current.persistPublicMetadata();
529
737
  }
530
738
  deleteReply(replyElement) {
531
739
  resetInactivityTimer();
532
- const {lat, lng, subject} = this;
740
+ const {lat, lng, tag} = this;
533
741
  const region = P2PWebNetwork.regionCode(lat, lng);
534
- const killTag = replyElement.dataset.subject;
742
+ const killTag = replyElement.dataset.tag;
535
743
  networkPromise.then(async contact => {
536
744
  // We won't be here unless we are the signer.
537
- await contact.publish({eventName: subject, region, killTag, payload: null});
745
+ await contact.publish({eventName: tag, region, killTag, payload: null});
538
746
  // IFF there's an attachment AND we're given msgIds by receiveChunkedBytes, then delete the attachment.
539
747
  const reply = this.getItem(killTag);
540
748
  const {attachmentTopic, msgIds = []} = reply?.payload || {};
@@ -546,7 +754,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
546
754
  }
547
755
  });
548
756
  }
549
- showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.subject, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
757
+ showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
550
758
  // Give OS notification that comes back to here, unless act is us.
551
759
  // All notifications on the same alert (e.g., the post and each reply) have the same tag, so OS can collapse them.
552
760
  if (agent === Agent.tag || !notificationsAllowed()) return;
@@ -564,12 +772,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
564
772
  registration.showNotification(hashtag, options);
565
773
  });
566
774
  }
567
- // Each reply element is a DIV.reply with data-subject and data-text attributes that are used in sharing.
775
+ // Each reply element is a DIV.reply with data-tag and data-text attributes that are used in sharing.
568
776
  // It contains an attribution header with controls, zero or one attachments, and then the message text.
569
777
  // If present the attachment will be an A element with download attribute, surrounding either an IMG, A/V player, or an attachment icon followed by the file name.
570
778
  formatReplies() { // Answer HTML for the replies and input box.
571
779
  const { items, agent, originalPosting } = this;
572
- const formatReply = ({subject, payload, ...rest}) => {
780
+ const formatReply = ({tag, payload, ...rest}) => {
573
781
  const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
574
782
  let text = message
575
783
  .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
@@ -587,7 +795,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
587
795
  </a>
588
796
  </div>`;
589
797
  const messageDisplay = message ? `<span class="message">${text}</span>` : '';
590
- let dataAttributes = `data-subject="${subject}" data-text="${message}"`;
798
+ let dataAttributes = `data-tag="${tag}" data-text="${message}"`;
591
799
  if (file) dataAttributes += ` data-file="${file}" data-name="${name}"`;
592
800
  return `<div class="reply" ${dataAttributes}>${this.formatAttribution(rest)}${attachment}${messageDisplay}</div>`;
593
801
  };
@@ -608,18 +816,23 @@ export class Alert extends Conversation { // A wrapper around L.marker
608
816
 
609
817
  async share(event) { // Share reply or post
610
818
  resetInactivityTimer();
611
- // TODO: Preserve attribution data. Maybe by including the subject reply tag in the url, and metadata in the text?
819
+ // TODO: Preserve attribution data. Maybe by including the tag reply tag in the url, and metadata in the text?
612
820
  const shareable = event.currentTarget.closest('[data-text]');
613
821
  const {text, file, name = 'unknown'} = shareable.dataset;
614
822
  const {lat, lng} = this;
615
823
  console.log('Share', shareable.dataset);
616
- const url = getShareableURL(this.subject, [this.hashtag]).href;
824
+ const url = getShareableURL(this.tag, [this.hashtag]).href;
617
825
  let textBase = `New CivilDefense.io alert @${lat},${lng}`;
618
826
  const extendedText = text ? `${textBase}\n${text}` : textBase;
619
827
  const data = {text: extendedText, url};
620
828
  if (file) data.files = [await P2PWebNetwork.dataURL2blob(file, name)];
621
829
  share(data);
622
830
  }
831
+ startExpiration(selector, remaining) { // Setup or update fader and destroy-on-expiration.
832
+ clearTimeout(this.destroyer);
833
+ this.destroyer = setTimeout(() => this.destroy(), remaining);
834
+ this.startFader(selector, remaining);
835
+ }
623
836
  startFader(selector, remaining) { // Set up or update fader on the specified marker element, returning that element.
624
837
  const { marker } = this;
625
838
  const markerElement = marker.getElement();
@@ -644,15 +857,5 @@ export class Alert extends Conversation { // A wrapper around L.marker
644
857
  }, interval);
645
858
  return element;
646
859
  }
647
- destroy() { // Remove this Alert pin entirely.
648
- clearInterval(this['.alert-pin']);
649
- clearInterval(this['.alert-commented']);
650
- clearInterval(this.destroyer);
651
- this.clearAvatars();
652
- // Unsubscribe from replies.
653
- networkPromise?.then(async contact => contact.subscribe({eventName: this.subject, region: this.region, handler: null}));
654
- this.marker.removeFrom(map);
655
- super.destroy();
656
- }
657
860
  }
658
861
  globalThis.Alert = Alert; // for debugging
@@ -19,7 +19,7 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
19
19
  for (const key in properties) {
20
20
  const existing = this[key];
21
21
  const proposed = properties[key];
22
- if (JSON.stringify(existing) !== JSON.stringify(proposed)) throw new Error(`Cannot update '${key}' from ${existing} to ${proposed}.`);
22
+ if (JSON.stringify(existing) !== JSON.stringify(proposed)) throw new Error(`Cannot update '${key}' in ${this.constructor.name} ${this.tag} from ${JSON.stringify(existing)} to ${JSON.stringify(proposed)}.`);
23
23
  }
24
24
  return this;
25
25
  }
@@ -32,13 +32,15 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
32
32
 
33
33
  static async ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
34
34
  const {tag, payload, ...rest} = data;
35
- let item = container.getItem(tag);
35
+ let item = container.getItem(tag); // existing item
36
36
  if (!payload) return item?.destroy();
37
+
38
+ // update or create item.
37
39
  if (item) item = await item.update(data);
38
40
  else item = await (new kind().initialize({...data, container}));
39
41
 
40
42
  if (!item) return container.removeItem(tag)?.destroy();
41
- return container.setItem(tag, item);
43
+ return container.setItem(item.tag, item); // initialize() may come up with a better tag.
42
44
  }
43
45
  }
44
46
 
@@ -251,7 +251,7 @@ export class P2PWebNetwork {
251
251
  }
252
252
  shortHealth(health = this.peer.health()) { // Info-report a short form of health.
253
253
  const roots = health.axonRoles.filter(r => r.isRoot);
254
- this.info(`disconnected with connections ${health.peers.map(P2PWebNetwork.short)}\nand ${roots.length ? `roots ${roots.map(r => P2PWebNetwork.short(r.topic))}.` : 'no roots.'}`);
254
+ this.info(`disconnected with ${health.peers.length} connections: ${health.peers.map(P2PWebNetwork.short)}\nand ${roots.length ? `roots ${roots.map(r => P2PWebNetwork.short(r.topic))}.` : 'no roots.'}`);
255
255
  }
256
256
  inRegionConnections(health = this.peer.health()) {
257
257
  const region = this.nodeIdentity.id.slice(0, 2);
@@ -6,7 +6,7 @@ let connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNE
6
6
  // dht 1 -> Axona
7
7
  // dht 0 -> server
8
8
  // dht -1 -> in-memory on client only
9
- const defaultDHT = 1;
9
+ const defaultDHT = globalThis.process ? 1 : 0; // Browser defaults to no Axona. Server nodes and alert-bot default to Axona.
10
10
  export const dht = parseInt((globalThis.process ?
11
11
  globalThis.process.env.DHT :
12
12
  new URL(globalThis.location).searchParams.get('dht')) ?? defaultDHT);
@@ -14,7 +14,7 @@ export const dht = parseInt((globalThis.process ?
14
14
  if (dht < 1) {
15
15
 
16
16
  const { v4:uuidv4 } = await import('uuid');
17
- const { getContainingCells, getPointInCell } = await import('./s2.js');
17
+ const { getSmallestCellId, getPointInCell } = await import('./s2.js');
18
18
  const { cellHex } = await import('./versions.js');
19
19
  const operator = await import('./pubsub.js');
20
20
 
@@ -38,8 +38,8 @@ if (dht < 1) {
38
38
  return {authorId: tag};
39
39
  };
40
40
  geoCellId = (lat, lng) => {
41
- const cells = getContainingCells(lat, lng);
42
- const hex = cellHex(cells[0]);
41
+ const cellid = getSmallestCellId(lat, lng);
42
+ const hex = cellHex(cellid);
43
43
  const sliced = hex.slice(0, 2);
44
44
  return parseInt(sliced, 16); // The worst way to do this.
45
45
  };
@@ -99,7 +99,7 @@ if (dht < 1) {
99
99
 
100
100
  if ((!subHandler && !inFlightResolver) || // debug
101
101
  ((typeof(subHandler) !== 'function') && (typeof(inFlightResolver) !== 'function')))
102
- console.log({tag, rest, subHandler, inFlightResolver, handlers, inFlight});
102
+ console.warn('no handler or request', {tag, rest, subHandler, inFlightResolver, handlers, inFlight});
103
103
 
104
104
  if (subHandler) return subHandler(...rest);
105
105
  delete inFlight[tag];
@@ -107,7 +107,7 @@ if (dht < 1) {
107
107
  };
108
108
  socket.onopen = () => {
109
109
  if (socket.readyState !== WebSocket.OPEN) return; // You would think that can't happen, but...
110
- resolve((...rest) => { // send()
110
+ resolve((...rest) => { // Promise the send() function.
111
111
  const tag = uuidv4();
112
112
  const {promise, resolve} = Promise.withResolvers();
113
113
  inFlight[tag] = resolve;
@@ -154,10 +154,12 @@ if (dht < 1) {
154
154
  kill(topic, msgId, options) {
155
155
  return send('unpublish', topic, msgId, options);
156
156
  },
157
+ lookup() { return {}; },
157
158
  host() {},
158
159
  unhost() {}
159
160
  };
160
161
  const status = {peers: 0, ms: 0}; // fixme ms
162
+ await new Promise(resolve => setTimeout(resolve, 1e3)); // Simulate finding peers, and give the app some time to do stuff.
161
163
  return { peer, nodeIdentity, transport, status, disconnect };
162
164
  };
163
165
 
@@ -1,9 +1,18 @@
1
1
  // In memory pubsub, for either client-only testing, or server-websocket testing
2
2
  const { v4:uuidv4 } = await import('uuid');
3
3
  const { TextEncoder, crypto, Buffer } = globalThis;
4
+ const publicationRolloverLimit = 1000;
4
5
 
5
6
  function setBucket(collection, type, topicId, subject, value) { // Set value in the collection.
6
7
  const bucket = collection[type][topicId] ||= {};
8
+ if ((collection === data) && (type === 'pub')) {
9
+ const keys = Object.keys(bucket);
10
+ const size = keys.length;
11
+ if (size >= publicationRolloverLimit) {
12
+ console.warn('Over pub limit on topic', topicId, size); // TODO: rotate out the earliest received.
13
+ delete bucket[keys[0]];
14
+ }
15
+ }
7
16
  bucket[subject] = value;
8
17
  }
9
18
  function removeBucket(collection, type, topicId, subject) { // Return the value and stop storing it.
@@ -20,7 +29,7 @@ const PUBLISH_TIMEOUT = 24 * 60 * 60e3; // Delete after 24 hours.
20
29
  const timeouts = {pub: {}, sub: {}};
21
30
  function expire(type, topicId, subject, remover, timeout) { // Cancellably schedule remover() to fire at timeout.
22
31
  if (!timeout) return;
23
- setBucket(timeouts, type, topicId, subject, setTimeout(remover, timeout));
32
+ setBucket(timeouts, type, topicId, subject, setTimeout(() => { remover(); cancel(type, topicId, subject); }, timeout));
24
33
  }
25
34
  function cancel(type, topicId, subject) { // Cancel a sheduled expiration.
26
35
  clearTimeout(removeBucket(timeouts, type, topicId, subject));
@@ -46,25 +55,34 @@ function normalizeTopic({name, region, owner, write = 'open'} = {}) {
46
55
  function deriveTopicId(topic) {
47
56
  return JSON.stringify(normalizeTopic(topic)); // No need to hash in this implementation.
48
57
  }
58
+ function delay(ms = 0) {
59
+ return ms && new Promise(resolve => setTimeout(resolve, ms));
60
+ }
49
61
 
50
62
  let invoke;
51
63
  export function setReceiver(receiver) {
52
64
  invoke = receiver;
53
65
  }
66
+ const throttleMS = 30; // Just to yield to other stuff.
67
+ async function pauseInvoke(...rest) {
68
+ invoke(...rest);
69
+ await new Promise(resolve => setTimeout(resolve, throttleMS));
70
+ }
54
71
 
55
- export function subscribe(topicName, nodeTag, {since = 'all'}) {
72
+ export async function subscribe(topicName, nodeTag, {since = 'all'}) {
56
73
  // Axona allows multiple handlers on the same topic, but we don't use that in civildefense, and do not implement it here.
57
74
  const topicId = deriveTopicId(topicName);
58
75
  const id = uuidv4();
59
76
  cancel('sub', topicId, id);
60
77
  expire('sub', topicId, id, () => deleteSub(topicId, id), SUBSCRIPTION_TIMEOUT);
61
78
  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.
79
+ if (since) { // invoke handler on any sticky data, but only after we have told client the subscription id.
80
+ await delay(100);
63
81
  let lastEnvelope = null, lastTime = 0;
64
82
  for (const envelope of getDataValues('pub', topicId)) {
65
83
  switch (since) {
66
84
  case 'all':
67
- invoke(nodeTag, id, envelope);
85
+ await pauseInvoke(nodeTag, id, envelope);
68
86
  break;
69
87
  case 'latest':
70
88
  if (envelope.ts > lastTime) {
@@ -73,11 +91,11 @@ export function subscribe(topicName, nodeTag, {since = 'all'}) {
73
91
  }
74
92
  break;
75
93
  default: // Must be a timestamp
76
- if (envelope.ts === since) invoke(nodeTag, id, envelope);
94
+ if (envelope.ts === since) await pauseInvoke(nodeTag, id, envelope);
77
95
  }
78
96
  }
79
97
  if (lastEnvelope) invoke(nodeTag, id, lastEnvelope);
80
- }, 100);
98
+ }
81
99
  return {topicName, topicId, id};
82
100
  }
83
101
 
@@ -92,7 +110,10 @@ export function deleteSubscriber(nodeTag) {
92
110
  for (const topicId in data.sub) {
93
111
  const keySubs = data.sub[topicId];
94
112
  for (const [subject, value] of Object.entries(keySubs)) {
95
- if (nodeTag === subject) deleteSub(topicId, subject, keySubs);
113
+ if (nodeTag === subject) {
114
+ deleteSub(topicId, subject, keySubs);
115
+ cancel('sub', topicId, subject);
116
+ }
96
117
  }
97
118
  }
98
119
  }
@@ -106,19 +127,19 @@ export async function publish(topic, message, {signWith}) {
106
127
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload));
107
128
  const msgId = toHex(new Uint8Array(hash));
108
129
  const envelope = {msgId, topic, ts: Date.now(), message, signerPubkey};
109
- for (const [nodeTag, id] of getDataEntries('sub', topicId)) invoke(nodeTag, id, envelope);
130
+ for (const [nodeTag, id] of getDataEntries('sub', topicId)) await pauseInvoke(nodeTag, id, envelope);
110
131
  setBucket(data, 'pub', topicId, msgId, envelope);
111
132
  expire('pub', topicId, msgId, () => removeBucket(data, 'pub', topicId, msgId), PUBLISH_TIMEOUT);
112
133
  return msgId;
113
134
  }
114
135
 
115
- export function unpublish(topic, msgId, {signWith}) {
136
+ export async function unpublish(topic, msgId, {signWith}) {
116
137
  const topicId = deriveTopicId(topic);
117
138
  cancel('pub', topicId, msgId);
118
139
  const envelope = removeBucket(data, 'pub', topicId, msgId);
119
140
  if (!envelope) return {ok: false}; // we didn't have it.
120
141
  envelope.deleted = true;
121
142
  envelope.message = null;
122
- for (const [nodeTag, id] of getDataEntries('sub', topicId)) invoke(nodeTag, id, envelope);
143
+ for (const [nodeTag, id] of getDataEntries('sub', topicId)) await pauseInvoke(nodeTag, id, envelope);
123
144
  return {ok: true};
124
145
  }
@@ -1,6 +1,7 @@
1
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
+ import { geoCellCenter/*, geoCellId*/ } from '@axona/protocol';
4
5
  const { BigInt } = globalThis;
5
6
 
6
7
  // s2 defines non-overlapping cells that completely cover the globe, at several different levels of cell-size.
@@ -12,43 +13,59 @@ const { BigInt } = globalThis;
12
13
  // Meanwhile as the user changes the area being shown, we subscribe to whatever cells we need in order to cover the display
13
14
  // area without overlapping cells.
14
15
 
15
- export const MIN_LEVEL = 3; // Corresponds to the top level Axona regions.
16
+ export const MIN_LEVEL = 3; // The largest cell that is still smaller than an Axona region.
16
17
  const MAX_S2_LEVEL = 30; // The leaf level that Cell.fromPoint operates at.
17
18
  const MAX_MAP_LEVEL = 17; // The max level that findCoverCellsByCenterAndRadius will use on our maps.
18
19
 
19
20
  const EARTH_RADIUS_METERS = 6371e3;
20
21
 
21
22
  export function getPointInCell(cellId) { // answer [lat, lng] in degrees from a BigInt
23
+ // const {lat, lng} = geoCellCenter(cellid);
24
+ // return [lat, lng];
22
25
  // CAUTION: This is intended for s2 level 3 or finer, and won't always work symmetrically for our top-level regions.
23
26
  // e.g. getContainingCells(...getPointInCell( cell for 0x47 )][0] is 0x46!
24
27
  let center = s2.cellid.latLng(cellId);
25
28
  return [s1.angle.degrees(center.lat), s1.angle.degrees(center.lng)];
26
29
  }
27
30
 
28
- function getCellSubdivision(cell) {
31
+ function getCellSubdivision(cell) { // Answer four children off BigInt cell, as BigInt.
29
32
  return cellid.children(cell);
30
33
  }
31
- function getCellLevel(cell) {
34
+ export function getSubdivision(hexString) { // Same as getCellSubDivision, but accepting and returning hex strings.
35
+ return getCellSubdivision(BigInt('0x' + hexString)).map(childCell => cellHex(childCell));
36
+ }
37
+ function getCellLevel(cell) { // Answer level of BigInt cell, as number.
32
38
  return cellid.level(cell);
33
39
  }
34
- function getFace(cell) {
40
+ function getCellFace(cell) { // Answer top level face id of BigInt cell, as integer 0 through 5.
35
41
  return cellid.face(cell);
36
42
  }
37
- export function getSubdivision(hexString) {
38
- return getCellSubdivision(BigInt('0x' + hexString)).map(childCell => cellHex(childCell));
43
+ export function cellContains(putativeOuter, putativeInner) { // Answer true IFF putativeOuter BigInt cell contains putativeInner
44
+ return cellid.contains(putativeOuter, putativeInner);
45
+ }
46
+ export function pointFromLatLng(lat, lng) { // Answer an s2 Point from lat/lng in degrees.
47
+ const latLng = LatLng.fromDegrees(lat, lng);
48
+ return Point.fromLatLng(latLng);
49
+ }
50
+ export function cellFromCellID(bigint) {
51
+ return Cell.fromCellID(bigint);
52
+ }
53
+ export function getSmallestCellId(lat, lng, level = MAX_MAP_LEVEL) { // Answer smallest BigInt cellid containing latitude/longitude in degrees, at integer level.
54
+ const userPt = pointFromLatLng(lat, lng);
55
+ const userLocCellId = Cell.fromPoint(userPt).id; // This is at MAX_S2_LEVEL
56
+ return cellid.parent(userLocCellId, level);
39
57
  }
40
58
 
41
- // Return a list of the cell ids that contain the point.
59
+ // Return a list of the cell ids that contain the point, from region to MAX_S2_LEVEL
60
+ // Note that the first of the cells (the region
42
61
  export function getContainingCells(lat, lng) {
43
- const userLatLng = LatLng.fromDegrees(lat, lng);
44
- const userPt = Point.fromLatLng(userLatLng);
45
- // Get leaf-level CellId (level 30)
46
- const userLocCellId = Cell.fromPoint(userPt).id; // This is at level 30.
47
- let cells = Array(MAX_S2_LEVEL);
48
- for (let level = 0; level <= MAX_S2_LEVEL; level++) { // This would be more efficient going backwards using immediateParent, but who cares.
49
- cells[level] = cellid.parent(userLocCellId, level);
62
+ let id = getSmallestCellId(lat, lng);
63
+ const level = MAX_MAP_LEVEL, cells = Array(MAX_MAP_LEVEL + 1 - MIN_LEVEL);
64
+ for (let index = level - MIN_LEVEL; index >= 0; index--) {
65
+ cells[index] = id;
66
+ id = cellid.immediateParent(id);
50
67
  }
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.
68
+ return cells;
52
69
  }
53
70
 
54
71
  export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, full = false,
@@ -58,8 +75,8 @@ export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, fu
58
75
 
59
76
  // 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
77
  // 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));
78
+ const lo = LatLng.fromDegrees(minLat, minLng);
79
+ const hi = LatLng.fromDegrees(maxLat, maxLng);
63
80
 
64
81
  const rect = full ? s2.Rect.fullRect() : new s2.Rect(
65
82
  new r1.Interval(lo.lat, hi.lat),
@@ -69,4 +86,3 @@ export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, fu
69
86
  const coverer = new RegionCoverer({minLevel, maxLevel, maxCells});
70
87
  return coverer.covering(rect); // a CellUnion — array-like of bigint cell IDs, already normalized/minimal
71
88
  }
72
-
@@ -25,10 +25,13 @@ export function cellHex(cellid) { // Convert cellid BigInt to properly padded he
25
25
  export function alertTopic(cellid, tag) { // Return topic name for public info about specified tag in cellid.
26
26
  return `civildefense.io:${dataVersion}:${cellHex(cellid)}:${canonicalTag(tag)}`;
27
27
  }
28
- export function topicCell(topicName) { // Return the cell that is baked in to the topic.
28
+ export function topicCellHex(topicName) { // Return the hex string cell that is baked in to the topic.
29
29
  // Note that canonicalTag(tag) may contain a colon, but dataVersion must not.
30
30
  return topicName.match(/civildefense.io:[^:]+:([a-z0-9]+):/)[1];
31
31
  }
32
+ export function topicCell(topicName) { // Return the BigInt cell that is baked in to the topic.
33
+ return BigInt('0x' + topicCellHex(topicName));
34
+ }
32
35
  export function topicRegion(topicName) { // Return the region that is baked in to the topic.
33
- return '0x' + topicCell(topicName).slice(0, 2);
36
+ return '0x' + topicCellHex(topicName).slice(0, 2);
34
37
  }
@@ -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.22';
3
+ const serviceVersion = '4.5.23';
4
4
 
5
5
  const cacheList = [ // The files we need.
6
6
  "/",
@@ -124,6 +124,8 @@ async function cacheFirst({request, event}) {
124
124
  try {
125
125
  const responseFromNetwork = await fetch(request);
126
126
  if (request.method !== 'GET' || request.cache === 'no-store') return responseFromNetwork; // Cache shouldn't allow anyway.
127
+ const mime = responseFromNetwork.headers.get("Content-Type");
128
+ if (/^(audio|video)\//.test(mime)) return responseFromNetwork; // Do not cache streamable media.
127
129
  // Put clone of response in cache (so that original can be returned.
128
130
  // Tell event to keep worker open while we put it, even though we return response immediately.
129
131
  const cache = await caches.open(serviceVersion);
@@ -254,6 +254,7 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
254
254
  .alert-marker {
255
255
  text-shadow: 8px -3px 10px rgb(0 0 0 / 60%);
256
256
  animation: pulse ease 0.8s 1;
257
+ transition: transform 0.8s, opacity 0.4s;
257
258
  }
258
259
  .alert-commented {
259
260
  display: none;
@@ -271,6 +272,30 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
271
272
  left: 5px;
272
273
  text-align: center;
273
274
  }
275
+ .aggregate.alert-pin {
276
+ font-size: 60px;
277
+ line-height: 60px;
278
+ top: -10px;
279
+ left: -10px;
280
+ transition: font-size 0.8s;
281
+ }
282
+ .aggregate.alert-pin:after {
283
+ content: "";
284
+ width: 70px;
285
+ height: 70px;
286
+ position: absolute;
287
+ top: -5px;
288
+ left: -5px;
289
+ background-image: radial-gradient(transparent 30%, var(--md-sys-color-primary), transparent 70%);
290
+ transition: opacity 0.8s;
291
+ animation: pulse ease 0.8s 1;
292
+ }
293
+ .aggregate.alert-pin.starting {
294
+ font-size: 0px;
295
+ }
296
+ .aggregate.alert-pin.starting:after {
297
+ opacity: 0;
298
+ }
274
299
  @keyframes pulse {
275
300
  0% { filter: saturate(1); }
276
301
  25% { filter: saturate(4); }
package/server/app.js CHANGED
@@ -14,18 +14,33 @@ const argv = yargs(hideBin(process.argv))
14
14
  .option('nPortals', {
15
15
  alias: 'p',
16
16
  type: 'number',
17
- default: Math.max(2, logicalCores - 3),
17
+ default: process.env.PORTALS ? parseInt(process.env.PORTALS) : Math.max(2, logicalCores - 3),
18
18
  description: "The number of steady nodes that handle initial connections."
19
19
  })
20
+ .option('regionStartIndex', {
21
+ type: 'number',
22
+ default: -1,
23
+ description: "If non-negative, place each of the nPortals in successive regions (excluding excludedRegionFromSeries) starting with the region at regionStaartIndex."
24
+ })
25
+ .option('excludedRegionFromSeries', {
26
+ type: 'string', array: true,
27
+ default: ['80', '88', '89'],
28
+ description: "If regionStartIndex is used, the ordered list of regions to occupy is the list of canonical regions less these."
29
+ })
20
30
  .option('baseURL', {
21
31
  type: 'string',
22
- default: 'http://localhost:3000',
32
+ default: process.env.BRIDGE_URL || 'wss://bridge.axona.net',
23
33
  description: "The base URL of the portal server through which to bootstrap."
24
34
  })
25
- .option('externalBaseURL', {
26
- type: 'string',
27
- default: '',
28
- description: "The base URL of the some other portal server to which we should connect ours, if any."
35
+ // .option('externalBaseURL', {
36
+ // type: 'string',
37
+ // default: '',
38
+ // description: "The base URL of the some other portal server to which we should connect ours, if any."
39
+ // })
40
+ .option('port', {
41
+ type: 'number',
42
+ default: 3000,
43
+ description: "Port for the server to listen on."
29
44
  })
30
45
  .option('announce', {
31
46
  type: 'boolean',
@@ -61,7 +76,6 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
61
76
  const logger = (await import('morgan')).default;
62
77
  const { configureWebsocket } = await import('./websocket.js');
63
78
 
64
- const port = parseInt((new URL(argv.baseURL)).port || '80');
65
79
  process.title = 'yz.social';
66
80
  const app = express();
67
81
  app.use(logger(':date[iso] :status :method :url :res[content-length] - :response-time ms'));
@@ -101,8 +115,8 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
101
115
  extensions: ['js'] // Some dependencies refer to .js files as relative pathnames, with the .js missing.
102
116
  }));
103
117
 
104
- server.listen(port);
105
- log(`Listening on ${port} and starting ${argv.nPortals} nodes on ${logicalCores} ${cpus()[0].model} logical cores.`);
118
+ server.listen(argv.port);
119
+ log(`Listening on ${argv.port} and starting ${argv.nPortals} nodes on ${logicalCores} ${cpus()[0].model} logical cores.`);
106
120
  for (let i = 0; i < argv.nPortals; i++) {
107
121
  cluster.fork();
108
122
  await delay(); // Number chosen to give an unspikey rise in packets/s.
@@ -118,8 +132,14 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
118
132
  } else {
119
133
  process.title = 'axona-starting';
120
134
  const { P2PWebNetwork, location } = await import('../index.js');
135
+ const { MAJORS, geoCellCenter } = await import('@axona/protocol');
136
+ const excluded = argv.excludedRegionFromSeries.map(hex => parseInt(hex, 16).toString());
137
+ const regions = Object.keys(MAJORS).filter(r => !excluded.includes(r));
138
+ const index = (cluster.worker.id - 1 + argv.regionStartIndex) % regions.length;
139
+ const region = (argv.regionStartIndex >= 0) && regions[index];
140
+ const loc = region ? geoCellCenter(parseInt(region)) : location;
121
141
  const network = await P2PWebNetwork.create({
122
- location,
142
+ location: loc,
123
143
  infoLogger: log,
124
144
  debugLogger: debug
125
145
  });
@@ -31,6 +31,7 @@ export function configureWebsocket(server) {
31
31
  ws.on('error', console.error);
32
32
  ws.on('close', () => {
33
33
  console.log('Disconnected', nodeTag);
34
+ delete sockets[nodeTag];
34
35
  operator.deleteSubscriber(nodeTag);
35
36
  });
36
37
  });
@@ -42,5 +43,5 @@ export function configureWebsocket(server) {
42
43
  ws.ping();
43
44
  return null;
44
45
  });
45
- }, 30e3);
46
+ }, 20e3);
46
47
  }