@yz-social/civildefense.io 4.5.22 → 4.5.28

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.
@@ -2,17 +2,20 @@ import * as L from 'leaflet';
2
2
  import { P2PWebNetwork } from './p2pWebNetwork.js';
3
3
  import { Int } from './translations.js';
4
4
  import { map, trackMap, showMessage } from './map.js';
5
- import { networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, openAbout, delay, osName } from './main.js';
5
+ import { networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, getText, openAbout, delay, osName } from './main.js';
6
6
  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, topicTag } 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
 
15
15
  export function getShareableURL(tag = null, tags = Hashtags.getSubscribe()) { // Answer a url that reflects application state.
16
+ // Regardless of whether an alert tag is given, the receiving user is always given a lat/lng:
17
+ // - To position their map with the same coverage as the sender
18
+ // - In case the alert has expired, rolled over, or included in an aggregate.
16
19
  const params = new URLSearchParams(location.search);
17
20
  const zoom = map.getZoom();
18
21
  const { lat, lng } = map.getCenter();
@@ -63,7 +66,9 @@ const ttl = 24 * 60 * 60e3; // 24 hours
63
66
  let openOnReceive = null;
64
67
  export function go({lat = null, lng = null, zoom = null, alert = null}) { // Go to specified location (if any) and open marker (if any).
65
68
  if (lat !== null && lng !== null) {
66
- if (zoom) map.flyTo({lat, lng}, zoom);
69
+ lat = parseFloat(lat);
70
+ lng = parseFloat(lng);
71
+ if (zoom) map.flyTo({lat, lng}, parseFloat(zoom));
67
72
  else map.flyTo({lat, lng});
68
73
  }
69
74
  openOnReceive = null;
@@ -102,13 +107,294 @@ class AlertReply extends Reply {
102
107
 
103
108
  import { s2 } from 's2js';
104
109
  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.
110
+ // For each hashtag, we subscribe to a set of non-overlapping cells at varying S2 levels that cover the current map.
111
+ // An Alert is made when a subscription handler fires, and it keeps information that is (mostly) true regardless of
112
+ // which cell brought it in. E.g., each alert has a bookkeeping tag that identifies that map-click, which is
113
+ // the same at all S2 levels, and forms the topic for replies to that alert. When the map changes and the subscriptions
114
+ // are updated, we try to re-use the same Alerts so that the markers don't flash and we don't create garbage.
115
+
116
+ // INSTANCE MANAGEMENT
117
+ // When there are "too many" instances in a cell (at any level), we replace the individual Alerts with a single larger aggregate.
118
+ // This allows us to handle any any quantity of Alerts in memory and visual clutter, and to not mislead users in the presence
119
+ // of publication topic "rollover". However, it greatly complicates insance management.
120
+
121
+ // Conversation.ensure is the subscription handler, and it keeps track of the instances by tag, initializing a new one if needed.
122
+ initialize({topic, payload, hashtag, tag, agent, issuedTime, ...rest}) { // Make appropriate instance for a new individual tag, or update aggregate.
123
+
124
+ const eventName = topic.name;
125
+ const subscriptions = this.constructor.subscriptions;
126
+ if (!Hashtags.isSubscribed(hashtag)) return null; // A subscribed event may have been in flight while unsubscribing. Caller destroys instance.
127
+ if (!subscriptions.hasOwnProperty(eventName)) return null; // An event may have been in flight while zooming or panning.
128
+ const now = Date.now(),
129
+ expiration = issuedTime + ttl,
130
+ remaining = expiration - now;
131
+ if (remaining < 0) return null; // Network shouldn't send us expired, but if it does, let its instance be destroyed.
132
+
133
+ let keep = this; // Instance to be kept as marker.
134
+ let aggregate = this.constructor.getAggregate(eventName); // If we already have one for this eventName
135
+
136
+ // Each new initialization gets counted, which may be more than the network rollover.
137
+ // We do not "back up" for deletions and expirations - i.e., subtract and possibly go back to individual alerts.
138
+ // 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.
139
+ subscriptions[eventName]++;
140
+
141
+ if (aggregate) {
142
+ keep = null; // This new instance is superfluous. Tell ensure() to destroy it...
143
+ aggregate.lerp(eventName, payload.lat, payload.lng); // ...but nudge the existing aggregate towards us.
144
+ } else { // Does not exist yet
145
+ let {lat, lng, originalPosting} = payload;
146
+ lat = parseFloat(lat);
147
+ lng = parseFloat(lng);
148
+ if (this.constructor.cellCountOverLimit(eventName)) aggregate = this; // If now over, treat this marker as an aggregate.
149
+ const icon = this.constructor.makeIcon(hashtag, tag, aggregate);
150
+ const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
151
+ const region = P2PWebNetwork.regionCode(lat, lng);
152
+ hashtag = Hashtags.add(hashtag); // We already have it and are subscribing, but this updates our extended form if needed.
153
+ super.initialize({payload, hashtag, tag, agent, lat, lng, issuedTime, originalPosting, ...rest});
154
+ if (aggregate) {
155
+ // Destroy existing eventName markers and add their positions to the aggregate we are creating.
156
+ this.becomeAggregate(eventName);
157
+ this.constructor.clearEventMarkers(eventName, aggregate);
158
+ } else {
159
+ this.noteEventName(eventName);
160
+ marker.bindPopup('', {className: 'alert'}).on('popupopen', event => this.ensureContent(event.popup));
161
+ tooltip(marker.getElement(), Int`Show conversation for this ${hashtag} alert.`);
162
+ if (tag === openOnReceive) { // Bug! How can we handle URLs to an alert that has been aggregated?
163
+ openOnReceive = false;
164
+ this.openPopup();
165
+ }
166
+ networkPromise.then(async contact => { // Subscribe to replies to this tag, now that we have an alert for them to go to.
167
+ contact.subscribe({eventName: tag, region, handler: data => this.ensure(data)});
168
+ });
169
+ }
170
+ }
171
+ const alert = aggregate || this;
172
+ alert.startExpiration('.alert-pin', remaining);
173
+ alert.showNotification({agent, issuedTime});
174
+ return keep;
175
+ }
176
+ update({topic, ts, ...rest}) { // Called when handling an existing Conversation. super confirms that nothing immutable has changed.
177
+ return super.update({...rest}); // topic and ts vary with level, and so must not be part of ensure/update checks.
178
+ }
179
+ async destroy(markerDelayMS = 400) { // Remove this Alert pin entirely, either through unpublish, expiration, or conversion of a cell to aggregate.
180
+ // We do not decrement Alert.subscriptions[this.eventName] and unaggregate into individual markers.
181
+ // That won't happen until the user completely unsubscribes from this cell and resubscribes (by toggle or map movement).
182
+ clearInterval(this['.alert-pin']);
183
+ clearInterval(this['.alert-commented']);
184
+ clearInterval(this.destroyer);
185
+ this.clearAvatars();
186
+ const {isAggregate, marker, tag, region} = this;
187
+ // Unsubscribe from replies.
188
+ if (!isAggregate) networkPromise?.then(async contact => contact.subscribe({eventName: tag, region, handler: null}));
189
+ this.cellBorder?.removeFrom(map);
190
+ super.destroy();
191
+ if (markerDelayMS) {
192
+ marker.closePopup();
193
+ marker.unbindPopup(); // It would be confusing if it happens to be open, or clicked on while being removed.
194
+ marker.setOpacity(0);
195
+ await P2PWebNetwork.delay(markerDelayMS);
196
+ }
197
+ marker.removeFrom(map);
198
+ }
199
+
200
+ static subscriptionQueue = Promise.resolve(); // Serialize updates so they don't overlap each other.
201
+ static async updateSubscriptions({newKeys, oldKeys, throttleMS = 20} = {}) { // Update current subscriptions.
202
+ // A value of {} passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
203
+ return this.subscriptionQueue = this.subscriptionQueue.then(async () => {
204
+ oldKeys ||= this.subscriptions;
205
+ newKeys ||= this.subscriptionFromMap();
206
+
207
+ if (!newKeys) return; // e.g., wacky computation. Don't change anything.
208
+ const contact = await networkPromise;
209
+ const dropped = [], added = [];
210
+ if (!contact) { console.warn("No network through which to subscribe."); return; } // Does this ever happen? Why?
211
+ this.subscriptions = newKeys; // Before subscribing.
212
+ const subscribe = async (eventName, handler) => {
213
+ if (!eventName) console.log('sub to no eventName', {oldKeys, newKeys, dropped, added, handler});
214
+ const region = topicRegion(eventName);
215
+ if (handler) Agent.current?.trackPublicChanges(region); // Background. No need to await.
216
+ await contact.subscribe({eventName, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
217
+ };
218
+ for (const key in newKeys) oldKeys.hasOwnProperty(key) || added.push(key);
219
+ for (const key in oldKeys) newKeys.hasOwnProperty(key) || dropped.push(key);
220
+ console.log('updating subscriptions', {added, dropped, newKeys, oldKeys});
221
+
222
+ // Before subscribing, as that that may bring in an alert with the same tag as one being cleared.
223
+ if (this.aggregateLimit) {
224
+ // TODO: more efficient way?
225
+ new Set(added.map(topicTag)).forEach(tag => {
226
+ const hasTag = topicName => topicTag(topicName) === tag;
227
+ this.transferOrClearEventMarkers(added.filter(hasTag), dropped.filter(hasTag), newKeys, oldKeys);
228
+ });
229
+ }
230
+
231
+ for (const key of added) await subscribe(key, data => Alert.ensure(data));
232
+ for (const key of dropped) await subscribe(key, null);
233
+ });
234
+ }
235
+
236
+ // Instance Management Internals
237
+ // In general here, a key is an eventName - i.e., a string <mumble>:<cellID>:<hashtag>.
238
+ // newKeys/oldKeys are a map of the currently subscribed eventName and the count of events for that cell+hashtag
108
239
  static subscriptions = {}; // maps currently active eventNames (<mumble>:<cellID>:<hashtag>) to count of event received for it.
240
+ static aggregateLimit = parseInt(new URLSearchParams(location.search).get('max') || 100); // How many individuals are allowed before we aggregate.
241
+ static MAX_MAP_ZOOM = 19; // If the map is already zoomed to this, there's no place else to go.
242
+ static cellCountOverLimit(eventName, countsDictionary = this.subscriptions) { // Have we received enough that we must show an aggregate?
243
+ return (countsDictionary[eventName] >= this.aggregateLimit) && (map.getZoom() < this.MAX_MAP_ZOOM);
244
+ }
245
+ static getAggregate(eventName) { // Answer the existing aggregate for this cell, if any.
246
+ return this.getItem(eventName); // While individual Alerts are stored in items/conversations by tag, the tag for aggregate is the eventName.
247
+ }
248
+ logAlert(label = '') { // For debugging, when an individual or aggregate marker is clicked, show the alert, cell, and count in console.
249
+ const {lat, lng, tag, eventName} = this;
250
+ console.warn(`${label} lat: ${lat}, lng: ${lng}, ${eventName}: ${this.constructor.subscriptions[eventName]}, ${tag}`);
251
+ }
252
+ noteEventName(eventName) { // Be a part of the specified grouping.
253
+ if (this.eventName === eventName) return;
254
+ this.eventName = eventName;
255
+ if (!this.isAggregate) return;
256
+ this.cellBorder?.removeFrom(map);
257
+ const cell = cellFromCellID(topicCell(eventName));
258
+ const radiansToDegrees = 180 / Math.PI;
259
+ const corners = Array.from({ length: 4 }, (_, i) => {
260
+ const point = cell.vertex(i);
261
+ const latLng = s2.LatLng.fromPoint(point);
262
+ return [latLng.lat * radiansToDegrees, latLng.lng * radiansToDegrees];
263
+ });
264
+ const border = this.cellBorder = L.polygon(corners, {color: this.constructor.md_sys_color_secondary, fillOpacity: 0.1});
265
+ border.addTo(map);
266
+ }
267
+ static md_sys_color_secondary = '#BD2E2F';
268
+ static forEachAlertOf(eventName, callback, alerts = this.items) { // Apply callback to each of alerts matching eventName.
269
+ // TODO? Record a cell's Alert's more efficiently, so that we don't have to cycle through everything.
270
+ alerts.forEach((alert, index, alerts) => (alert.eventName === eventName) && callback(alert, index, alerts));
271
+ }
272
+ static clearEventMarkers(eventName, aggregate = null, alerts = this.items) { // destroy each, but
273
+ // if aggregate is specified, keep the aggregate and average all position into the aggregate.
274
+ if (!aggregate) return this.forEachAlertOf(eventName, alert => alert.destroy(), alerts);
275
+ const eachExceptAggregate = cb => this.forEachAlertOf(eventName, alert => (alert === aggregate) || cb(alert), alerts);
276
+ let lat = aggregate.lat, lng = aggregate.lng, count = 1;
277
+ if (aggregate.eventName != eventName) { // Loading from another eventName. Keep existing weight.
278
+ count = this.subscriptions[aggregate.eventName];
279
+ lat *= count;
280
+ lng *= count;
281
+ }
282
+ eachExceptAggregate(alert => { lat += alert.lat; lng += alert.lng; count++; });
283
+ lat /= count;
284
+ lng /= count;
285
+ return this.forEachAlertOf(eventName, alert => {
286
+ alert.reposition(lat, lng);
287
+ if (alert !== aggregate) alert.destroy(400); // May or may not be default. Half the translation transition time.
288
+ }, alerts);
289
+ }
290
+ becomeAggregate(eventName) { // Make an individual alert be an aggregate
291
+ const {tag, marker, hashtag, region} = this;
292
+ const element = marker.getElement();
293
+ const pin = element.querySelector('.alert-pin');
294
+ this.isAggregate = true;
295
+ pin.classList.toggle('aggregate', true); pin.classList.toggle('starting', true);
296
+ setTimeout(() => pin.classList.toggle('starting', false), 100);
297
+ marker.unbindPopup();
298
+ marker.off('click');
299
+ marker.on('click', event => {
300
+ this.logAlert();
301
+ this.constructor.zoomOn(this.lat, this.lng, hashtag);
302
+ });
303
+ tooltip(element, Int`Zoom in on multiple alerts in this area.`);
304
+ this.noteEventName(this.tag = eventName);
305
+ if (tag !== eventName) {
306
+ networkPromise.then(async contact => contact.subscribe({eventName: tag, region, handler: null})); // Unsubscribe from replies.
307
+ this.items = [];
308
+ clearInterval(this['.alert-commented']);
309
+ element.querySelector('.alert-commented').style = "opacity: 0;";
310
+ this.constructor.removeItem(tag);
311
+ }
312
+ this.constructor.setItem(eventName, this);
313
+ }
314
+ static handleAggregation(droppedEventName, addedEventName, newCounts, alerts) { // Return aggregate if over limit, setting it up if necessary. Else null.
315
+ // If over limit, converts an addedEventName Alert if needed and clears the rest, and then clears all droppedEventName Alerts.
316
+ if (!this.cellCountOverLimit(addedEventName, newCounts)) return null;
317
+ let aggregate = this.getAggregate(addedEventName);
318
+ if (!aggregate) { // If no existing aggregate:
319
+ aggregate = alerts.find(alert => alert.eventName === droppedEventName); // Pick a dropped individual to become aggregate. There will be one, by construction.
320
+ aggregate.becomeAggregate(addedEventName);
321
+ this.clearEventMarkers(addedEventName, aggregate, alerts); // Kill the rest, adding their positions to the aggregate.
322
+ }
323
+ return aggregate;
324
+ }
325
+ static transferOrClearEventMarkers(added, dropped, newCounts, oldCounts) { // Clear dropped alerts as necessary,
326
+ // but try to preserve (individual and aggregated) alerts so that the markers don't flash.
327
+ const addedCells = added.map(topicCell); // List of BigInt, in same order as added eventNames.
328
+ const alerts = this.items;
329
+ dropped.sort((a, b) => oldCounts[b] - oldCounts[a]); // So that we always process aggregates before related individuals.
330
+ dropped.forEach(droppedEventName => {
331
+ const droppedCell = topicCell(droppedEventName);
332
+ // Whether zooming in or out, each dropped cell may have added cells that contain it, or vice versa.
333
+ // We don't have to worry about cells that are neither added nor dropped, because those will not overlap added or dropped.
334
+
335
+ // Find the one added cell (if any) that contains the dropped cell. (Typically when zooming out.)
336
+ const containerIndex = addedCells.findIndex(added => cellContains(added, droppedCell));
337
+ if (containerIndex >= 0) {
338
+ const addedContainingEventName = added[containerIndex];
339
+ newCounts[addedContainingEventName] += oldCounts[droppedEventName]; // It all goes to the container, which may have already started filling.
340
+ const aggregate = this.handleAggregation(droppedEventName, addedContainingEventName, newCounts, alerts);
341
+ if (aggregate) {
342
+ this.clearEventMarkers(droppedEventName, aggregate, alerts); // Absorb the discarded. Their distinctiveness will be added to our own.
343
+ return;
344
+ }
345
+ this.forEachAlertOf(droppedEventName, alert => alert.noteEventName(addedContainingEventName)); // Label the dropped individual alerts for new container.
346
+ return;
347
+ }
348
+
349
+ const aggregate = this.getAggregate(droppedEventName);
350
+ if (aggregate) { // We don't know how mucch of this will be included in any added subregion, and cannot reconstruct where they were.
351
+ aggregate.destroy();
352
+ return;
353
+ }
354
+
355
+ // Find the 0-4 added cells that are contained by the dropped cell. (Typically when zooming in.)
356
+ const addedSubCells = addedCells.filter(added => cellContains(droppedCell, added));
357
+ // We don't know how many oldCounts to distribute to each subcell, so we have to work with each dropped alert's location.
358
+ if (addedSubCells.length) {
359
+ const addedS2Cells = addedSubCells.map(cellFromCellID);
360
+ this.forEachAlertOf(droppedEventName, alert => {
361
+ const point = pointFromLatLng(alert.lat, alert.lng);
362
+ const addedS2Cell = addedS2Cells.find(cell => cell.containsPoint(point));
363
+ if (addedS2Cell) {
364
+ const includedIndex = addedCells.indexOf(addedS2Cell.id);
365
+ const addedIncludedEventName = added[includedIndex];
366
+ newCounts[addedIncludedEventName] += 1;
367
+ if (this.handleAggregation(droppedEventName, addedIncludedEventName, newCounts, alerts)) return;
368
+ alert.noteEventName(addedIncludedEventName); // Leave this alert in place, but label where it belongs.
369
+ } else {
370
+ alert.destroy(); // This alert is not within an added subcell.
371
+ }
372
+ }, alerts);
373
+ return;
374
+ }
375
+
376
+ // Otherwise, no overlap, so clear the (individual and aggregate) markers of the dropped cell.
377
+ this.clearEventMarkers(droppedEventName);
378
+ });
379
+ }
380
+ lerp(eventName, additionaLatitude, additionalLongitude) { // Move this Alert towards this location, inversely weight by count in cell.
381
+ const count = this.constructor.subscriptions[eventName];
382
+ const k = 1 / count;
383
+ const k1 = 1 - k;
384
+ const {lat, lng} = this;
385
+ this.reposition(k1 * lat + k * additionaLatitude,
386
+ k1 * lng + k * additionalLongitude);
387
+ }
388
+ reposition(lat, lng) { // Update the marker's position for new data.
389
+ this.lat = lat;
390
+ this.lng = lng;
391
+ this.marker.setLatLng([lat, lng]);
392
+ this.startExpiration('.alert-pin', Date.now() + ttl);
393
+ }
394
+
109
395
  // We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
110
396
  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.
397
+ static subscriptionFromMap() { // Generate new subscriptions list ({eventName => count}) for current map bounds.
112
398
  const center = map.getCenter();
113
399
  const bounds = map.getBounds();
114
400
  const northEast = bounds.getNorthEast();
@@ -123,84 +409,54 @@ export class Alert extends Conversation { // A wrapper around L.marker
123
409
  });
124
410
  if (!newCells) return null;
125
411
  const newKeys = {};
126
-
127
- newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => {
412
+ newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => { // Populate count with existing count (exctly carried over cell sizes), else 0.
128
413
  const eventName = alertTopic(cell, hash);
129
414
  newKeys[eventName] = this.subscriptions[eventName] || 0;
130
415
  }));
131
416
  // Record a zoomed-out cell id in case next session does not have geolocation services.
132
- let level9Cell = getContainingCells(center.lat, center.lng)[9];
417
+ let level9Cell = getSmallestCellId(center.lat, center.lng, 9);
133
418
  if (level9Cell !== this.lastLevel9Cell) localStorage.setItem('level9Cell', this.lastLevel9Cell = level9Cell);
134
419
  return newKeys;
135
420
  }
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
- }
421
+
422
+ // PUBLISHING NEW ALERTS
423
+ // Each map click publishes to that point at each supported S2 level that contains it, so that any map display of
424
+ // non-overlapping cells will catch it. (Network subscriptions are much more expensive than publishing, and the app
425
+ // users watch more than they publish, so we minimize the number of subscriptions that a user has at any moment.)
426
+
427
+ // The app (not the network) restricts the user to publish up to maxPublish alerts over the last maxPublish minutes.
428
+ // I.e., one / minute, but allowing bursts up to maxPublish. After that, one can still publish, but kill the oldest.
429
+ // 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
430
  static maxPublish = 5;
182
431
  static publishing = false;
183
- static lastPublished = []; // Last published lat, lng, tag
432
+ static lastPublished = []; // Last published lat, lng, hashtag
184
433
  // Publish an alert to all applicable eventNames, canceling as required. Promises tag (msgId).
185
434
  static async publish({lat, lng,
186
435
  originalPosting = undefined,
187
436
  hashtag = Hashtags.getPublish(true),
188
- payload = {lat, lng, originalPosting}, // If payload is null (cancels subject), lat & lng are still used to generate eventNames.
437
+ payload = {lat, lng, originalPosting}, // If payload is null (cancels tag), lat & lng are still used to generate eventNames.
189
438
  cancel = undefined, // First unpublish the specified data, if any. Complicated default.
190
- issuedTime = Date.now(), subject,
439
+ issuedTime = Date.now(), tag,
191
440
  throttleMS = 0,
192
441
  ...rest
193
442
  }) {
194
- // We call all the publishing at once and return subject, without waiting for each to occur.
443
+ // We call all the publishing at once and return tag, without waiting for each to occur.
195
444
  // However, the 'unpublishing' (if any) is invoked first.
196
445
  // To do this, we must hash the eventName ourselves.
197
- //console.log('publish', {lat, lng, hashtag, payload, cancel, subject, issuedTime, rest});
446
+ //console.log('publish', {lat, lng, hashtag, payload, cancel, tag, issuedTime, rest});
198
447
  if (this.publishing) { console.log('skiping overlapping publish'); return null; } // do not stack them up.
199
448
  try {
200
449
  this.publishing = true;
450
+ const cells = getContainingCells(lat, lng);
451
+ const eventNames = cells.map(cell => alertTopic(cell, hashtag));
452
+ if (payload && eventNames.some(eventName => this.getAggregate(eventName))) { // Attemtpt to publish where we are showing an aggregate.
453
+ // There could already be an alert there, so zoom instead.
454
+ this.zoomOn(lat, lng, hashtag);
455
+ return null;
456
+ }
201
457
 
202
458
  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.
459
+ let oldCells = null, oldHash, oldTag = null; // Recorded for logging, below.
204
460
  let lastFillIn;
205
461
  if (payload) {
206
462
  lastFillIn = {lat, lng, hashtag, issuedTime};
@@ -213,55 +469,57 @@ export class Alert extends Conversation { // A wrapper around L.marker
213
469
  }
214
470
  }
215
471
  if (cancel) {
216
- const {lat, lng, hashtag, subject} = cancel;
472
+ const {lat, lng, hashtag, tag} = cancel;
217
473
  oldCells = getContainingCells(lat, lng);
218
- oldHash = hashtag; oldSubject = subject;
474
+ oldHash = hashtag; oldTag = tag;
219
475
  const region = P2PWebNetwork.regionCode(lat, lng);
220
476
  for (const cell of oldCells) {
221
477
  const eventName = alertTopic(cell, hashtag);
222
478
  // Note: we cannot unpublish replies by others, but they expire after a while anyway.
223
- await contact.publish({eventName, region, killTag: subject, payload: null});
479
+ await contact.publish({eventName, region, killTag: tag, payload: null});
224
480
  throttleMS && await P2PWebNetwork.delay(throttleMS);
225
481
  }
226
482
  }
227
483
 
228
- const cells = getContainingCells(lat, lng);
229
484
  const region = P2PWebNetwork.regionCode(lat, lng);
230
- for (const cell of cells) {
231
- const eventName = alertTopic(cell, hashtag);
485
+ for (const eventName of eventNames) {
232
486
  if (payload) {
233
487
  // The Axona message will be {hashtag, issuedTime, payload:{lat, lng, originalPosting}}
234
488
  // and when combined with the publisher's authorId will be unique to this user/time/hashtag,
235
489
  // and yet the same for each of the individual publications at the different s2 scales.
236
490
  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;
491
+ if (tag && tag !== msgId) throw new Error(`msgId is drifting: ${tag} => ${msgId}`);
492
+ tag = msgId;
239
493
  if (lastFillIn) {
240
- lastFillIn.subject = subject;
494
+ lastFillIn.tag = tag;
241
495
  lastFillIn = null;
242
496
  }
243
497
  } else {
244
- await contact.publish({eventName, region, killTag: subject, payload: null});
498
+ await contact.publish({eventName, region, killTag: tag, payload: null});
245
499
  throttleMS && await P2PWebNetwork.delay(throttleMS);
246
500
  }
247
501
  }
248
502
  if (!payload) {
249
- const index = this.lastPublished.findIndex(past => past.subject === subject);
503
+ const index = this.lastPublished.findIndex(past => past.tag === tag);
250
504
  if (index >= 0) this.lastPublished.splice(index, 1);
251
505
  }
252
- console.log('Published', {cells, n: cells.length, region, hashtag, subject, payload, oldCells, oldHash, oldSubject});
253
- return subject;
506
+ console.log('Published', {cells, n: cells.length, region, hashtag, tag, payload, oldCells, oldHash, oldTag});
507
+ return tag;
254
508
  } finally {
255
509
  this.publishing = false;
256
510
  }
257
511
  }
512
+ static zoomOn(lat, lng, hashtag = '', zoom = map.getZoom() + 1) {
513
+ if (hashtag) showMessage(Int`There are already at least ${this.aggregateLimit} alerts of type "${hashtag}" in the colored region. Zooming in to get a better look.`);
514
+ map.setZoomAround([lat, lng], zoom);
515
+ }
258
516
 
259
517
  static noMessage = Int`No additional information.`;
260
518
  static closePopup() { // Close any open popup.
261
519
  map.closePopup();
262
520
  Hashtags.closeSelector();
263
521
  }
264
- static openPopup(alertTag) { // Open the marker specified by subject.
522
+ static openPopup(alertTag) { // Open the marker specified by tag.
265
523
  const wrapper = this.getItem(alertTag);
266
524
  wrapper?.openPopup() || (openOnReceive = alertTag);
267
525
  }
@@ -274,9 +532,10 @@ export class Alert extends Conversation { // A wrapper around L.marker
274
532
  }
275
533
  this.marker.openPopup();
276
534
  }
277
- static makeIcon(hashtag) { // Return a Leaflet icon. TODO: are these cacheable and reusable?
535
+ static makeIcon(hashtag, tag, isAggregate = false) { // Return a Leaflet icon.
536
+ // tag is handy for debugging. TODO: are these cacheable and reusable?
278
537
  return L.divIcon({
279
- html: `<div class="alert-commented"></div><div class="alert-pin">${Hashtags.formatAlert(hashtag)}</div>`,
538
+ html: `<div class="alert-commented"></div><div class="alert-pin${isAggregate ? ' aggregate' : ''}" data-debug="${tag}">${Hashtags.formatAlert(hashtag)}</div>`,
280
539
  iconSize: [40, 40],
281
540
  popupAnchor: [0, 0],
282
541
  className: 'alert-marker'
@@ -286,12 +545,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
286
545
  this.items.forEach(wrapper => {
287
546
  const { hashtag, marker, agent } = wrapper;
288
547
  if (hashtag !== canonicalHashtag) return;
289
- const newIcon = this.makeIcon(extendedHashtag);
290
- const popup = marker.getPopup();
548
+ const newIcon = this.makeIcon(extendedHashtag, wrapper.tag);
291
549
  marker.setIcon(newIcon);
292
550
  wrapper.hashtag = extendedHashtag;
293
551
  wrapper.needsRedisplay = true; // See comment for initializeHandlers. We need to clear and rebuild content on re-open.
294
- if (!popup.isOpen()) return;
552
+ const popup = marker.getPopup();
553
+ if (!popup?.isOpen()) return; // Either an aggregate or closed.
295
554
  // Fix what's showing now without flashing everything. Make sure menu works.
296
555
  const popupAttribution = popup.getElement().querySelector('.attribution');
297
556
  const attributionActions = popupAttribution.lastElementChild;
@@ -300,45 +559,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
300
559
  wrapper.initChangeHashtag(popupAttribution);
301
560
  });
302
561
  }
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
562
 
339
563
  needsRedisplay = true;
340
564
  ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
565
+ if (!popup) return;
341
566
  if (!popup.isOpen()) return;
567
+ this.logAlert();
342
568
  if (!this.needsRedisplay) {
343
569
  this.initializeHandlers(popup);
344
570
  return;
@@ -352,8 +578,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
352
578
  delay(100).then(() => {
353
579
  this.marker.getPopup().update();
354
580
  this.initializeHandlers(popup);
581
+ this.teach('firstConversation');
582
+ if (Agent.isMine(this.agent)) this.teach('firstPublish');
355
583
  });
356
- console.warn(`latitude: ${this.lat}, longitude: ${this.lng}`);
584
+ }
585
+ teach(classname) {
586
+ if (localStorage.getItem(classname)) return;
587
+ localStorage.setItem(classname, '1');
588
+ document.body.classList.toggle(classname, true);
357
589
  }
358
590
  clearAvatars(popup = this.marker?.getPopup()) {
359
591
  popup?.getElement()?.querySelectorAll('.correspondent[data-tag]')
@@ -365,6 +597,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
365
597
  const replyButton = replyInput.querySelector('md-filled-icon-button');
366
598
  const replyAttachButton = replyInput.querySelector('md-tonal-icon-button');
367
599
  const fileChooser = popupElement.querySelector('input[type="file"]');
600
+ replyInput.onfocus = event => resetInactivityTimer();
368
601
  replyInput.oninput = event => {
369
602
  replyButton.removeAttribute('disabled');
370
603
  const input = event.currentTarget;
@@ -373,7 +606,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
373
606
  input.rows = internalHighWater;
374
607
  };
375
608
  clickTip(replyButton, Int`Post your reply.`, event => this.postReply(event));
376
- clickTip(replyAttachButton, Int`Attach a file to your reply.`, event => { resetInactivityTimer(); fileChooser.click(); });
609
+ clickTip(replyAttachButton, getText('.teach.attach'), event => { resetInactivityTimer(); fileChooser.click(); });
377
610
  fileChooser.onchange = event => {
378
611
  resetInactivityTimer();
379
612
  replyButton.removeAttribute('disabled');
@@ -388,9 +621,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
388
621
  const isAvatar = correspondent.classList.contains('avatar');
389
622
  if (agent.addElement(correspondent, 'mixed', isAvatar ? 'avatar' : 'handle')) {
390
623
  const isMine = Agent.isMine(tag);
391
- clickTip(correspondent, isMine ?
392
- Int`Control how others see me.` :
393
- Int`Control how this person is labeled on my device.`,
624
+ clickTip(correspondent, getText((isMine ? '.firstPublish' : '.firstConversation') + ' .teach.correspondent'),
394
625
  event => {
395
626
  if (isMine) openAbout(event);
396
627
  else agent.describe(event);
@@ -409,20 +640,20 @@ export class Alert extends Conversation { // A wrapper around L.marker
409
640
  const shareable = popupElement.querySelectorAll('.share');
410
641
  for (const element of shareable) clickTip(element, element.closest('.reply') ?
411
642
  Int`Share though ${osName()} the text and attachments of this reply, with a link to open this alert.` :
412
- Int`Share through ${osName()} a link to open this alert.`, event => this.share(event));
643
+ getText('.teach.share'), event => this.share(event));
413
644
  }
414
645
  initChangeHashtag(someParent) { // Init handler on the menu button, if any, as (re-) init of menu for open popup
415
646
  const changeHashtag = someParent.querySelector('.changeHashtag');
416
647
  if (!changeHashtag) return;
417
648
  const menu = document.getElementById('popoverMenu');
418
649
  menu.anchorElement = changeHashtag;
419
- clickTip(changeHashtag, Int`Change the topic or delete your alert.`, event => {
650
+ clickTip(changeHashtag, getText('.teach.changeHashtag'), event => {
420
651
  consume(event);
421
652
  menu.open = !menu.open;
422
653
  menu.onclick = consume; // Must be onlick rather than addEventListener.
423
654
  const handler = event => {
424
655
  menu.removeEventListener('close-menu', handler);
425
- this.updatePost(event.detail.initiator.dataset.tag);
656
+ this.updatePost(event.detail.initiator.dataset.tag); // initiator is a hashtag menu item and dataset.tag is a hashtag.
426
657
  };
427
658
  menu.addEventListener('close-menu', handler); // Must be addEventListener because there's no onclosemenu.
428
659
  });
@@ -467,16 +698,16 @@ export class Alert extends Conversation { // A wrapper around L.marker
467
698
  ${actions}
468
699
  </div>`;
469
700
  }
470
- updatePost(newTag) { // Republish under a different hashtag, or cancel altogether if no newTag (which is not allowed as a hashtag).
701
+ updatePost(newHashtag) { // Republish under a different hashtag, or cancel altogether if no newHashtag (which is not allowed as a hashtag).
471
702
  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);
703
+ const {lat, lng, hashtag, tag, issuedTime, originalPosting = issuedTime} = this;
704
+ console.log("updatePost", {newHashtag, lat, lng, hashtag, tag, issuedTime, originalPosting, self:this});
705
+ if (!newHashtag) return Alert.publish({lat, lng, tag, originalPosting, hashtag, payload: null, cancel: null}); // Remove post with null payload, cancel.
706
+ if (newHashtag === hashtag) return this.needsRedisplay = true;
707
+ const cancel = {lat, lng, tag, hashtag}; // Cancel old hashtag as we publish newHashtag, below.
708
+ Hashtags.setPublish(newHashtag);
478
709
  Hashtags.onchange({redisplaySubscribers: false, resetSubscriptions: false});
479
- return Alert.publish({lat, lng, hashtag: newTag, originalPosting, cancel}); // Publish new alert w/cancellation.
710
+ return Alert.publish({lat, lng, hashtag: newHashtag, originalPosting, cancel}); // Publish new alert w/cancellation.
480
711
  }
481
712
 
482
713
  // Each reply is separately published by its author, and only they can modify/unpublish it.
@@ -484,7 +715,6 @@ export class Alert extends Conversation { // A wrapper around L.marker
484
715
  return AlertReply;
485
716
  }
486
717
  async ensure(data) { // Add or update reply for this reply.
487
- data.subject = data.tag; //fixme
488
718
  const remaining = data.issuedTime + ttl - Date.now();
489
719
  if (remaining < 0) return null;
490
720
  const reply = await super.ensure(data);
@@ -507,13 +737,13 @@ export class Alert extends Conversation { // A wrapper around L.marker
507
737
  this.ensureContent();
508
738
  return reply;
509
739
  }
510
- async postReply(event) { // Post a reply to this marker's subject, in response to a text-field change event.
740
+ async postReply(event) { // Post a reply to this marker's tag, in response to a text-field change event.
511
741
  resetInactivityTimer();
512
742
  event.stopPropagation();
513
743
  const button = event.target;
514
744
  const inputElement = button.parentElement;
515
745
  let payload = inputElement.value.trim();
516
- const {subject, hashtag, lat, lng} = this;
746
+ const {tag, hashtag, lat, lng} = this;
517
747
  const region = P2PWebNetwork.regionCode(lat, lng);
518
748
  const files = inputElement.parentElement.querySelector('input[type="file"]').files;
519
749
  if (!payload && !files.length) return;
@@ -524,17 +754,17 @@ export class Alert extends Conversation { // A wrapper around L.marker
524
754
  const {topic:file, msgIds} = await contact.chunkifyBlob({blob: files[0], region});
525
755
  payload = {message: payload, file};
526
756
  }
527
- await contact.publish({eventName: subject, region, payload}); // Publish the new reply.
757
+ await contact.publish({eventName: tag, region, payload}); // Publish the new reply.
528
758
  Agent.current.persistPublicMetadata();
529
759
  }
530
760
  deleteReply(replyElement) {
531
761
  resetInactivityTimer();
532
- const {lat, lng, subject} = this;
762
+ const {lat, lng, tag} = this;
533
763
  const region = P2PWebNetwork.regionCode(lat, lng);
534
- const killTag = replyElement.dataset.subject;
764
+ const killTag = replyElement.dataset.tag;
535
765
  networkPromise.then(async contact => {
536
766
  // We won't be here unless we are the signer.
537
- await contact.publish({eventName: subject, region, killTag, payload: null});
767
+ await contact.publish({eventName: tag, region, killTag, payload: null});
538
768
  // IFF there's an attachment AND we're given msgIds by receiveChunkedBytes, then delete the attachment.
539
769
  const reply = this.getItem(killTag);
540
770
  const {attachmentTopic, msgIds = []} = reply?.payload || {};
@@ -546,7 +776,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
546
776
  }
547
777
  });
548
778
  }
549
- showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.subject, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
779
+ showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
550
780
  // Give OS notification that comes back to here, unless act is us.
551
781
  // All notifications on the same alert (e.g., the post and each reply) have the same tag, so OS can collapse them.
552
782
  if (agent === Agent.tag || !notificationsAllowed()) return;
@@ -564,12 +794,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
564
794
  registration.showNotification(hashtag, options);
565
795
  });
566
796
  }
567
- // Each reply element is a DIV.reply with data-subject and data-text attributes that are used in sharing.
797
+ // Each reply element is a DIV.reply with data-tag and data-text attributes that are used in sharing.
568
798
  // It contains an attribution header with controls, zero or one attachments, and then the message text.
569
799
  // 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
800
  formatReplies() { // Answer HTML for the replies and input box.
571
801
  const { items, agent, originalPosting } = this;
572
- const formatReply = ({subject, payload, ...rest}) => {
802
+ const formatReply = ({tag, payload, ...rest}) => {
573
803
  const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
574
804
  let text = message
575
805
  .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 +817,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
587
817
  </a>
588
818
  </div>`;
589
819
  const messageDisplay = message ? `<span class="message">${text}</span>` : '';
590
- let dataAttributes = `data-subject="${subject}" data-text="${message}"`;
820
+ let dataAttributes = `data-tag="${tag}" data-text="${message}"`;
591
821
  if (file) dataAttributes += ` data-file="${file}" data-name="${name}"`;
592
822
  return `<div class="reply" ${dataAttributes}>${this.formatAttribution(rest)}${attachment}${messageDisplay}</div>`;
593
823
  };
@@ -608,18 +838,23 @@ export class Alert extends Conversation { // A wrapper around L.marker
608
838
 
609
839
  async share(event) { // Share reply or post
610
840
  resetInactivityTimer();
611
- // TODO: Preserve attribution data. Maybe by including the subject reply tag in the url, and metadata in the text?
841
+ // TODO: Preserve attribution data. Maybe by including the tag reply tag in the url, and metadata in the text?
612
842
  const shareable = event.currentTarget.closest('[data-text]');
613
843
  const {text, file, name = 'unknown'} = shareable.dataset;
614
844
  const {lat, lng} = this;
615
845
  console.log('Share', shareable.dataset);
616
- const url = getShareableURL(this.subject, [this.hashtag]).href;
846
+ const url = getShareableURL(this.tag, [this.hashtag]).href;
617
847
  let textBase = `New CivilDefense.io alert @${lat},${lng}`;
618
848
  const extendedText = text ? `${textBase}\n${text}` : textBase;
619
849
  const data = {text: extendedText, url};
620
850
  if (file) data.files = [await P2PWebNetwork.dataURL2blob(file, name)];
621
851
  share(data);
622
852
  }
853
+ startExpiration(selector, remaining) { // Setup or update fader and destroy-on-expiration.
854
+ clearTimeout(this.destroyer);
855
+ this.destroyer = setTimeout(() => this.destroy(), remaining);
856
+ this.startFader(selector, remaining);
857
+ }
623
858
  startFader(selector, remaining) { // Set up or update fader on the specified marker element, returning that element.
624
859
  const { marker } = this;
625
860
  const markerElement = marker.getElement();
@@ -644,15 +879,5 @@ export class Alert extends Conversation { // A wrapper around L.marker
644
879
  }, interval);
645
880
  return element;
646
881
  }
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
882
  }
658
883
  globalThis.Alert = Alert; // for debugging