@yz-social/civildefense.io 4.5.21 → 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.
@@ -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