@yz-social/civildefense.io 4.5.9 → 4.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { P2PWebNetwork } from "./public/javascripts/p2pWebNetwork.js";
2
- export { agentTopic, alertTopic, canonicalTag } from "./public/javascripts/versions.js";
2
+ export { topicRegion, agentTopic, alertTopic, canonicalTag } from "./public/javascripts/versions.js";
3
3
  export { getContainingCells } from "./public/javascripts/s2.js";
4
4
  export { location } from "./server/getLocation.js";
5
+ export { deriveTopicIdBig } from "@axona/protocol";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yz-social/civildefense.io",
3
3
  "description": "Browser app to safely share live sightings on a map.",
4
- "version": "4.5.9",
4
+ "version": "4.5.21",
5
5
  "keywords": [
6
6
  "map",
7
7
  "browser",
@@ -10,14 +10,16 @@
10
10
  "scripts": {
11
11
  "start": "npm stop; node ./server/app.js",
12
12
  "stop": "pkill yz.social",
13
- "postinstall": "touch server/location.json; chmod a+w server/location.json"
13
+ "postinstall": "touch server/location.json; chmod a+w server/location.json",
14
+ "prepack": "mv server/location.json server/location.save.json",
15
+ "postpack": "mv server/location.save.json server/location.json"
14
16
  },
15
17
  "type": "module",
16
18
  "exports": {
17
19
  ".": "./index.js"
18
20
  },
19
21
  "dependencies": {
20
- "@axona/protocol": "github:axona-net/axona-protocol#semver:^4.48.0",
22
+ "@axona/protocol": "github:axona-net/axona-protocol#semver:^4.59.2",
21
23
  "cors": "^2.8.6",
22
24
  "express": "^4.22.1",
23
25
  "leaflet": "^1.9.4",
@@ -27,6 +29,7 @@
27
29
  "pica": "^9.0.1",
28
30
  "s2js": "^1.44.0",
29
31
  "uuid": "^14.0.0",
32
+ "ws": "^8.21.3",
30
33
  "yargs": "^18.0.0"
31
34
  },
32
35
  "overrides": {
Binary file
package/public/index.html CHANGED
@@ -25,12 +25,11 @@
25
25
  "uuid": "https://unpkg.com/uuid@13.0.0/dist/index.js",
26
26
  "@material/web/": "https://esm.run/@material/web/",
27
27
  "s2js": "./s2js/s2js.esm.js",
28
- "bigfloat": "./bigfloat/esm/index.js",
29
- "leaflet": "./leaflet/leaflet-src.esm.js",
30
- "minidenticons": "./minidenticons/minidenticons.min.js",
31
- "@axona/protocol": "./axona-protocol/src/index.js",
32
- "@axona/protocol/std": "./axona-protocol/std/index.js",
33
- "@axona/protocol/connect.js": "./axona-protocol/src/connect.js"
28
+ "bigfloat": "./bigfloat/esm/index.js",
29
+ "leaflet": "./leaflet/leaflet-src.esm.js",
30
+ "minidenticons": "./minidenticons/minidenticons.min.js",
31
+ "@axona/protocol": "./axona-protocol/src/index.js",
32
+ "@axona/protocol/std": "./axona-protocol/std/index.js"
34
33
  }
35
34
  }
36
35
  </script>
@@ -161,6 +160,7 @@
161
160
  </div>
162
161
 
163
162
  <md-menu positioning="popover" id="popoverMenu"></md-menu>
163
+ <ul class="combobox-listbox hidden" id="knownTagsListbox" role="listbox"></ul>
164
164
  </section>
165
165
  <script type="module" src="javascripts/main.js"></script>
166
166
  </body>
@@ -1,7 +1,6 @@
1
- const { localStorage } = globalThis;
1
+ const { localStorage, URL } = globalThis;
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
  import { minidenticonSvg } from 'minidenticons';
4
- import { createAuthorIdentity } from '@axona/protocol';
5
4
  import { agentTopic, agentPersistKey } from './versions.js';
6
5
  import { Int } from './translations.js';
7
6
  import { consume, openDisplay } from './display.js';
@@ -54,23 +53,30 @@ export class Agent {
54
53
  const value = localStorage.getItem(this.localPersistKey(type, tag));
55
54
  this.updateValue(value, scope, type, false); // Don't publish until we post.
56
55
  }
57
- trackedRegions = {};
58
- currentRegion = null;
59
- trackPublicChanges(region) {
60
- this.currentRegion = region;
61
- if (this.trackedRegions[region]) return;
62
- if (Agent.isMine(this.tag)) this.persistPublicMetadata(region);
56
+
57
+ // A map may show multiple regions, and we want to show consistent attribution metadata for alerts in each.
58
+ // Thus we have to keep track of each region where we have activity, and subscribe to changes in each such region for
59
+ // each mentioned avatar instance. See trackPublicChanges.
60
+ //
61
+ // For our agent(s), we also need to keep track of the current msgId so that we can kill it properly when we change value.
62
+ // This will be different in each tracked region.
63
+ //
64
+ trackedRegions = {}; // region code => {avatar, handle} last msgIds for this agent as known.
65
+ trackPublicChanges(region) { // Subscribe to changes for this agent instance's metdata in this region.
66
+ // We do this for for each Agent.ensure that appears for an alert or reply.
67
+ // and also for our own Agent.current when subscribing to a topic, so that we have the right killTag even if we have not published THIS session.
68
+ if (this.trackedRegions[region]) return; // Already subscribed.
63
69
  networkPromise.then(contact => {
64
- this.trackedRegions[region] = true;
70
+ this.trackedRegions[region] = {};
65
71
  const owner = this.tag;
66
72
  ['handle', 'avatar'].forEach(type => {
67
73
  const eventName = this.networkPersistKey(type);
68
- contact.subscribe({eventName, region, owner, /*since: 'latest',*/ handler: data => this.setPublicData({...data, type})});
74
+ contact.subscribe({eventName, region, owner, since: 'latest', handler: data => this.setPublicData({...data, type, region})});
69
75
  });
70
76
  });
71
77
  }
72
- async setPublicData(data) { // Subscription to public data has fired. Update value, but do not not re-publish.
73
- let {payload, tag, type, topic, ts} = data; // fixme remove topic and ts
78
+ async setPublicData(data) { // Subscription to public data has fired. Update value, but do not not re-publish (e.g., if our agent).
79
+ let {payload, tag, type, region, topic, ts} = data;
74
80
  // WARNING: IF we chunkify avatars, and we use since:'all', then we need lock out asynchronous
75
81
  // decoding of later timestamps, or of null payloads.
76
82
  // if (payload && (type === 'avatar')) {
@@ -79,7 +85,8 @@ export class Agent {
79
85
  // payload = dataURL;
80
86
  // }
81
87
  this.updateValue(payload, 'public', type, false);
82
- if (tag) this.publicMsgId[type] = tag;
88
+ if (payload) this.trackedRegions[region][type] = tag;
89
+ else delete this.trackedRegions[region][type];
83
90
  }
84
91
 
85
92
  static agents = {}; // tag => Agent
@@ -94,12 +101,12 @@ export class Agent {
94
101
  handle: {system: null, public: null, private: null, mixed: null},
95
102
  avatar: {system: null, public: null, private: null, mixed: null}
96
103
  };
97
- publicMsgId = {}; // maps type => msgId for saved public data of this Agent instance.
98
104
  getValue(scope, type) {
99
105
  return this.values[type][scope];
100
106
  }
101
107
  updateValue(value, scope, type, pushPublic = true) { // Updates dependent elements, and if necessary, the mixed values/elements as well.
102
- if (this.values[type][scope] === value) return;
108
+ const match = this.values[type][scope] === value;
109
+ if (match) return null;
103
110
 
104
111
  // Persist if private. For public, update locally but do not publish until this agent publishes an alert or reply.
105
112
  if (scope === 'private') this.persistPrivate(value, type);
@@ -121,31 +128,41 @@ export class Agent {
121
128
  if (value === null) localStorage.removeItem(key);
122
129
  else localStorage.setItem(key, value);
123
130
  }
124
- async persistPublicMetadata(region) { // Publish handle and avatar.
125
- this.currentRegion = region;
131
+ async persistPublicMetadata() { // Publish handle and avatar.
132
+ // Used by Agent.current when we post an alert or reply.
133
+ // There could already be such a publication active, but republishing ensures that it is as fresh as
134
+ // the alert/reply (e.g., with respect to expirations).
126
135
  await Promise.all(['handle', 'avatar'].map(type => this.persistPublic(this.getValue('public', type) || null, type)));
127
136
  }
128
137
  async persistPublic(value, type) { // Publish (and we will act on subscription).
138
+ // We persist to ALL our tracked regions, so that anyone in those regions will get the latest metadata.
139
+ // Note that we might be tracking regions A and B, while someone else is tracking B and C.
140
+ // The other user will get the new value through B, and that will be THE value displayed on their machine.
141
+ // BUT... if the other user tracks B and gets the new value, and then moves into region C and gets a
142
+ // stale value, they will see the stale value in both regions going forward in that session until we update in that region.
129
143
  const eventName = this.networkPersistKey(type);
130
- const region = this.currentRegion;
131
144
  const owner = this.tag;
132
- // TODO: set owner as well.
133
145
  const contact = await networkPromise;
134
- if (value) {
135
- let payload = value;
136
- // Our downsampling is such that we do not need to chunkify.
137
- // if (type === 'avatar') {
138
- // const blob = await P2PWebNetwork.dataURL2blob(value);
139
- // payload = (await contact.chunkifyBlob({blob, region})).topic;
140
- // }
141
- return contact.publish({eventName, region, owner, payload});
146
+
147
+ for (let region in this.trackedRegions) {
148
+ // First kill previous, if any.
149
+ // Publish doesn't know whether subscribers will be 'since' all or 'latest', so it must retain all unkilled.
150
+ // Thus if we only kill the last one when there is no value,
151
+ // a subscribe since:latest will produce the PREVIOUS value -- the last unkilled one.
152
+
153
+ const killTag = this.trackedRegions[region][type];
154
+ if (killTag) await contact.publish({eventName, region, owner, killTag, payload: null});
155
+
156
+ if (value) {
157
+ let payload = value;
158
+ // Our downsampling is such that we do not need to chunkify.
159
+ // if (type === 'avatar') {
160
+ // const blob = await P2PWebNetwork.dataURL2blob(value);
161
+ // payload = (await contact.chunkifyBlob({blob, region})).topic;
162
+ // }
163
+ contact.publish({eventName, region, owner, payload});
164
+ }
142
165
  }
143
- // For now, until since:'latest' works, supply a null payload and no subject.
144
- await contact.publish({eventName, region, owner, payload: null});
145
- // const subject = this.publicMsgId[type];
146
- // if (!subject) return null; // We have not published a value, so nothing to kill.
147
- // await contact.publish({eventName, region, owner, subject, payload: null});
148
- // return null;
149
166
  }
150
167
 
151
168
  // We represent handles and avatars by inserting stuff into given elements.
@@ -156,8 +173,9 @@ export class Agent {
156
173
  element.textContent = value || '(none)';
157
174
  element.value = value || ''; // Hack: handle input[type="text"] as well. Must be property assignment, not attribute.
158
175
  }
176
+ static spinner = 'images/loading.gif';
159
177
  static avatar(element, value) { // Update avatar element with value.
160
- element.innerHTML = value === null ? '(none)' : (value.startsWith('data') ? this.makeImage(value) : Agent.makeIdenticon(value));
178
+ element.innerHTML = value === null ? '(none)' : (value === this.spinner || value.startsWith('data') ? this.makeImage(value) : Agent.makeIdenticon(value));
161
179
  }
162
180
  static downsampleResolution = 128; // max height or width
163
181
  static makeImage(url) {
@@ -237,11 +255,8 @@ export class Agent {
237
255
  this.updateValue(null, 'private', 'avatar');
238
256
  };
239
257
  fileChooser.onchange = async event => {
240
- consume(event);
241
- if (!fileChooser.files.length) return;
242
- const blob = await P2PWebNetwork.downsampledBlob({blob: fileChooser.files[0], maxDimension: Agent.downsampleResolution});
243
- this.updateValue(await P2PWebNetwork.blob2dataURL(blob), 'private', 'avatar');
244
- console.log('clearing avatar selection');
258
+ await this.fileEvent(fileChooser, event, 'private');
259
+ console.log('cleared avatar selection');
245
260
  };
246
261
  fileChooser.click();
247
262
  });
@@ -263,19 +278,31 @@ export class Agent {
263
278
  content.parentElement.classList.toggle('hidden', true);
264
279
  };
265
280
  }
281
+ async fileEvent(fileChooser, event, scope) { // Update value from fileChooser change event, return dataURL;
282
+ consume(event);
283
+ if (!fileChooser.files.length) return '';
284
+ const file = fileChooser.files[0];
285
+ const maxDimension = Agent.downsampleResolution;
286
+ const blob = await P2PWebNetwork.downsampledBlob({blob: file, maxDimension});
287
+ const dataURL = await P2PWebNetwork.blob2dataURL(blob);
288
+ await this.updateValue(dataURL, scope, 'avatar');
289
+ return dataURL;
290
+ }
266
291
  static current = null;
267
292
  static tag = null;
268
293
  static identity = null;
294
+ // Keep user separate between dht=1 or empty (Axona) vs dht=0 or -1 (no Axona, for testing).
295
+ static usertagKey = `usertag${parseInt(new URL(location).searchParams.get('dht')) < 1 ? '0' : ''}`;
269
296
  static switchUser(tag, identity) { // Set/persist/ensure the current user, return Agent
270
297
  this.tag = tag; // Before the ensure().
271
298
  this.identity = P2PWebNetwork.currentPublishIdentity = identity;
272
- localStorage.setItem('usertag', this.tag);
299
+ localStorage.setItem(this.usertagKey, this.tag);
273
300
  return this.current = this.ensure({tag, identity});
274
301
  }
275
302
  static async initialize() { // Initialize what the agent needs from the about screen
276
- let tag = localStorage.getItem('usertag');
303
+ let tag = localStorage.getItem(this.usertagKey);
277
304
  const persistAs = tag || uuidv4(); // Give it SOMETHING to persistAs.
278
- const myIdentity = await createAuthorIdentity({persistAs});
305
+ const myIdentity = await P2PWebNetwork.createAuthorIdentity({persistAs});
279
306
  if (tag !== persistAs) { // Fix up persistence by moving it to where it need to go.
280
307
  // WARNING: This relies on undocumented behavior of createAuthorIdentity().
281
308
  const realTag = myIdentity.authorId;
@@ -304,21 +331,18 @@ export class Agent {
304
331
  fileChooser.oncancel = event => {
305
332
  consume(event);
306
333
  console.log('cancel my avatar');
334
+ myAgent.updateValue(this.spinner, 'public', 'avatar', false);
307
335
  myAgent.updateValue(null, 'public', 'avatar');
308
336
  myAgent.persistPrivate(null, 'avatar'); // So that we'll have it next session.
309
337
  console.log('clearing avatar selection');
310
338
  };
311
339
  fileChooser.onchange = async event => {
312
- consume(event);
313
- if (!fileChooser.files.length) return;
314
- const blob = await P2PWebNetwork.downsampledBlob({blob: fileChooser.files[0], maxDimension: Agent.downsampleResolution});
315
- const url = await P2PWebNetwork.blob2dataURL(blob);
340
+ myAgent.updateValue(this.spinner, 'public', 'avatar', false);
341
+ const url = await myAgent.fileEvent(fileChooser, event, 'public');
316
342
  console.log('set my avatar', url.slice(0, 50));
317
- myAgent.updateValue(url, 'public', 'avatar');
318
343
  myAgent.persistPrivate(url, 'avatar'); // So that we'll have it next session.
319
344
  };
320
345
  fileChooser.click();
321
346
  });
322
347
  }
323
348
  }
324
-
@@ -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 } from './versions.js';
11
- import { getContainingCells, findCoverCellsByCenterAndPoint } from './s2.js';
10
+ import { alertTopic, topicRegion, topicCell, cellHex } from './versions.js';
11
+ import { getContainingCells, getSubdivision, findCoverCellsByMinMaxLatLng } from './s2.js';
12
12
  const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
13
13
 
14
14
 
@@ -17,7 +17,7 @@ export function getShareableURL(tag = null, tags = Hashtags.getSubscribe()) { //
17
17
  const zoom = map.getZoom();
18
18
  const { lat, lng } = map.getCenter();
19
19
 
20
- params.set('tags', tags.map(tag => encodeURIComponent(tag)).join(','));
20
+ if (tags.length) params.set('tags', tags.map(tag => encodeURIComponent(tag)).join(','));
21
21
  if (lat !== null) params.set('lat', lat);
22
22
  if (lng !== null) params.set('lng', lng);
23
23
  if (zoom !== null) params.set('z', zoom);
@@ -68,7 +68,7 @@ export function go({lat = null, lng = null, zoom = null, alert = null}) { // Go
68
68
  }
69
69
  openOnReceive = null;
70
70
  if (alert) {
71
- Alert.openPopup(alert) || (openOnReceive = alert);
71
+ Alert.openPopup(alert);
72
72
  }
73
73
  }
74
74
 
@@ -94,62 +94,97 @@ class AlertReply extends Reply {
94
94
  const {dataURL:file, name, msgIds} = await contact.assembleChunkedDataURL(attachmentTopic);
95
95
  Object.assign(payload, {file, name, attachmentTopic, msgIds});
96
96
  }
97
- const element = container.startFader('.alert-commented', issuedTime + ttl - Date.now());
98
- element.style.display = 'block';
99
- // Restart the pulse animation by setting animationName to something it isn't.
100
- element.style.animationName = element.style.animationName === 'pulse2' ? 'pulse' : 'pulse2';
101
97
  container.showNotification({agent, issuedTime, body: payload.message || payload.name || payload});
102
98
  return this;
103
99
  }
100
+ update() { } // TODO: are we really getting multiple reply events for the same data?
104
101
  }
105
102
 
106
- let subscriptions = []; // array of stringy keys <mumble>:<cellID>:<hashtag>
107
- let subscriptionsRegion;
108
- // We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
109
- let lastLevel9Cell; // S2 level 9 cells average a radius of about 10km ~ 6.5 miles.
110
-
111
- let last = []; // Last published lat, lng, tag
112
- const maxPublish = 5;
113
- let publishing = false;
114
-
103
+ import { s2 } from 's2js';
115
104
  export class Alert extends Conversation { // A wrapper around L.marker
116
105
  // When we resubscribe to different cells covering the same place, we will get the same
117
106
  // sticky data. We don't want to change the marker. Fortunately, the publication to each
118
107
  // of the cells (at different scales) are all published with the same data.
119
- static updateSubscriptions(oldKeys = subscriptions, newKeys) { // Update current subscriptions to the new map bounds.
120
- // A value of [] passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
121
- if (!networkPromise) { console.warn("No network through which to subscribe."); return; } // Does this ever happen? Why?
122
- let region;
123
- if (!newKeys) { // None specified. Compute them.
124
- const center = map.getCenter();
125
- const bounds = map.getBounds();
126
- const northEast = bounds.getNorthEast();
127
- const newCells = findCoverCellsByCenterAndPoint(center.lat, center.lng, northEast.lat, northEast.lng); // array of cell IDs (BigInts)
128
- region = P2PWebNetwork.regionCode(center.lat, center.lng);
129
- newKeys = newCells.flatMap(cell => Hashtags.getSubscribe().map(hash => alertTopic(cell, hash)));
130
- Agent.current?.trackPublicChanges(region);
131
- // Record a zoomed-out cell id in case next session does not have geolocation services.
132
- let level9Cell = getContainingCells(center.lat, center.lng)[9];
133
- if (level9Cell !== lastLevel9Cell) localStorage.setItem('level9Cell', lastLevel9Cell = level9Cell);
134
- }
135
-
136
- const subscribe = (key, region, handler) =>
137
- networkPromise.then(async contact => contact.subscribe({eventName: key, region, handler}));
108
+ static subscriptions = {}; // maps currently active eventNames (<mumble>:<cellID>:<hashtag>) to count of event received for it.
109
+ // We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
110
+ 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.
112
+ const center = map.getCenter();
113
+ const bounds = map.getBounds();
114
+ const northEast = bounds.getNorthEast();
115
+ const southWest = bounds.getSouthWest();
116
+ const zoom = map.getZoom();
117
+ const newCells = findCoverCellsByMinMaxLatLng({
118
+ full: zoom <= map.options.minZoom,
119
+ minLat: southWest.lat,
120
+ maxLat: northEast.lat,
121
+ minLng: southWest.lng,
122
+ maxLng: northEast.lng
123
+ });
124
+ if (!newCells) return null;
125
+ const newKeys = {};
138
126
 
127
+ newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => {
128
+ const eventName = alertTopic(cell, hash);
129
+ newKeys[eventName] = this.subscriptions[eventName] || 0;
130
+ }));
131
+ // Record a zoomed-out cell id in case next session does not have geolocation services.
132
+ let level9Cell = getContainingCells(center.lat, center.lng)[9];
133
+ if (level9Cell !== this.lastLevel9Cell) localStorage.setItem('level9Cell', this.lastLevel9Cell = level9Cell);
134
+ return newKeys;
135
+ }
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});
139
176
  // For each entry in the new subscription set that was not previously subscribed, subscribe now.
140
- for (const key of newKeys) oldKeys.includes(key) || subscribe(key, region, data => Alert.ensure(data));
141
-
177
+ for (const key in newKeys) oldKeys.hasOwnProperty(key) || await subscribe(key, data => Alert.ensure(data));
142
178
  // For each existing subscription, if it does not appear in the new set then unsubscribe.
143
- for (const key of oldKeys) newKeys.includes(key) || subscribe(key, subscriptionsRegion, null);
144
- console.log('Subscribed', {newKeys, region, length: newKeys.length, oldKeys, subscriptionsRegion});
145
-
146
- subscriptions = newKeys;
147
- subscriptionsRegion = region;
179
+ for (const key in oldKeys) newKeys.hasOwnProperty(key) || await subscribe(key, null);
148
180
  }
181
+ static maxPublish = 5;
182
+ static publishing = false;
183
+ static lastPublished = []; // Last published lat, lng, tag
149
184
  // Publish an alert to all applicable eventNames, canceling as required. Promises tag (msgId).
150
185
  static async publish({lat, lng,
151
186
  originalPosting = undefined,
152
- hashtag = Hashtags.getPublish(),
187
+ hashtag = Hashtags.getPublish(true),
153
188
  payload = {lat, lng, originalPosting}, // If payload is null (cancels subject), lat & lng are still used to generate eventNames.
154
189
  cancel = undefined, // First unpublish the specified data, if any. Complicated default.
155
190
  issuedTime = Date.now(), subject,
@@ -160,21 +195,21 @@ export class Alert extends Conversation { // A wrapper around L.marker
160
195
  // However, the 'unpublishing' (if any) is invoked first.
161
196
  // To do this, we must hash the eventName ourselves.
162
197
  //console.log('publish', {lat, lng, hashtag, payload, cancel, subject, issuedTime, rest});
163
- if (publishing) { console.log('skiping overlapping publish'); return null; } // do not stack them up.
198
+ if (this.publishing) { console.log('skiping overlapping publish'); return null; } // do not stack them up.
164
199
  try {
165
- publishing = true;
200
+ this.publishing = true;
166
201
 
167
202
  const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
168
203
  let oldCells = null, oldHash, oldSubject = null; // Recorded for logging, below.
169
204
  let lastFillIn;
170
205
  if (payload) {
171
206
  lastFillIn = {lat, lng, hashtag, issuedTime};
172
- last.push(lastFillIn); // Capture the added data.
173
- const periodStart = Date.now() - (maxPublish * 60e3); // maxPublish minutes ago.
174
- last = last.filter(past => past.issuedTime >= periodStart);
175
- if (cancel === undefined && last.length > maxPublish) { // Unless specified otherwise, cancel oldest over maxPublish.
176
- showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.`);
177
- cancel = last.shift();
207
+ this.lastPublished.push(lastFillIn); // Capture the added data.
208
+ const periodStart = Date.now() - (this.maxPublish * 60e3); // maxPublish minutes ago.
209
+ this.lastPublished = this.lastPublished.filter(past => past.issuedTime >= periodStart);
210
+ if (cancel === undefined && this.lastPublished.length > this.maxPublish) { // Unless specified otherwise, cancel oldest over maxPublish.
211
+ showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.`, 'instructions');
212
+ cancel = this.lastPublished.shift();
178
213
  }
179
214
  }
180
215
  if (cancel) {
@@ -195,6 +230,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
195
230
  for (const cell of cells) {
196
231
  const eventName = alertTopic(cell, hashtag);
197
232
  if (payload) {
233
+ // The Axona message will be {hashtag, issuedTime, payload:{lat, lng, originalPosting}}
234
+ // and when combined with the publisher's authorId will be unique to this user/time/hashtag,
235
+ // and yet the same for each of the individual publications at the different s2 scales.
198
236
  const msgId = await contact.publish({eventName, region, payload, issuedTime, hashtag, ...rest});
199
237
  if (subject && subject !== msgId) throw new Error(`msgId is drifting: ${subject} => ${msgId}`);
200
238
  subject = msgId;
@@ -208,13 +246,13 @@ export class Alert extends Conversation { // A wrapper around L.marker
208
246
  }
209
247
  }
210
248
  if (!payload) {
211
- const index = last.findIndex(past => past.subject === subject);
212
- if (index >= 0) last.splice(index, 1);
249
+ const index = this.lastPublished.findIndex(past => past.subject === subject);
250
+ if (index >= 0) this.lastPublished.splice(index, 1);
213
251
  }
214
252
  console.log('Published', {cells, n: cells.length, region, hashtag, subject, payload, oldCells, oldHash, oldSubject});
215
253
  return subject;
216
254
  } finally {
217
- publishing = false;
255
+ this.publishing = false;
218
256
  }
219
257
  }
220
258
 
@@ -225,7 +263,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
225
263
  }
226
264
  static openPopup(alertTag) { // Open the marker specified by subject.
227
265
  const wrapper = this.getItem(alertTag);
228
- wrapper?.openPopup();
266
+ wrapper?.openPopup() || (openOnReceive = alertTag);
229
267
  }
230
268
  async openPopup() { // Open this wrapper's popup, and resolve any waiting promise.
231
269
  const { resolveGo } = this; // A handy hook for scripting.
@@ -276,6 +314,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
276
314
  }
277
315
  initialize({payload, hashtag, subject, agent, issuedTime, ...rest}) { // Set up the marker for a newly received alert.
278
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.
279
318
  const icon = this.constructor.makeIcon(hashtag);
280
319
  const {lat, lng, originalPosting} = payload;
281
320
  const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
@@ -444,9 +483,26 @@ export class Alert extends Conversation { // A wrapper around L.marker
444
483
  get itemKind() { // Answer class of reply items.
445
484
  return AlertReply;
446
485
  }
447
- async ensure(data) { // Add or update reply for this marker.
486
+ async ensure(data) { // Add or update reply for this reply.
448
487
  data.subject = data.tag; //fixme
488
+ const remaining = data.issuedTime + ttl - Date.now();
489
+ if (remaining < 0) return null;
449
490
  const reply = await super.ensure(data);
491
+ if (reply) {
492
+ if (reply === this.items[0]) { // If first sorted reply, and there's a message, update the tooltip.
493
+ const message = reply.payload?.message || (!reply.payload.file && reply.payload);
494
+ const markerElement = message && this.marker.getElement();
495
+ if (markerElement) tooltip(markerElement, message);
496
+ }
497
+ if (reply === this.items[this.items.length - 1]) { // If last reply so far (even if first), show ring and update fader.
498
+ const ringElement = this.startFader('.alert-commented', remaining);
499
+ if (ringElement) {
500
+ ringElement.style.display = 'block';
501
+ // Restart the pulse animation by setting animationName to something it isn't.
502
+ ringElement.style.animationName = ringElement.style.animationName === 'pulse2' ? 'pulse' : 'pulse2';
503
+ }
504
+ }
505
+ }
450
506
  this.needsRedisplay = true;
451
507
  this.ensureContent();
452
508
  return reply;
@@ -469,7 +525,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
469
525
  payload = {message: payload, file};
470
526
  }
471
527
  await contact.publish({eventName: subject, region, payload}); // Publish the new reply.
472
- Agent.current.persistPublicMetadata(region);
528
+ Agent.current.persistPublicMetadata();
473
529
  }
474
530
  deleteReply(replyElement) {
475
531
  resetInactivityTimer();
@@ -499,7 +555,11 @@ export class Alert extends Conversation { // A wrapper around L.marker
499
555
  const icon = new URL('./images/civil-defense-192.png', location.href).href;
500
556
  const url = getShareableURL(alert, [hashtag]).href; // For opening page when it has been closed.
501
557
  const data = {lat, lng, url};
502
- const options = {icon, timestamp, tag: alert, body, data};
558
+ // It appears that on 8/14/26:
559
+ // Safari ignores tag/renotify, and ALWAYS tells the user and displays each notification separately, without consolidating by tag.
560
+ // Chrome ignores renotify, and ALWAYS consolidates by tag, replacing old body with new, and NEVER renotifies the user (for the same tag).
561
+ // So... we could get uniform behavior by skipping the tag, but for now we'll try using it as intended, in case the browsers ever start to comply.
562
+ const options = {icon, timestamp, tag: alert, body, data, renotify: true};
503
563
  console.log('showNotification', hashtag, options);
504
564
  registration.showNotification(hashtag, options);
505
565
  });
@@ -512,8 +572,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
512
572
  const formatReply = ({subject, payload, ...rest}) => {
513
573
  const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
514
574
  let text = message
515
- .replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}"></audio>`) // show audio urls as players
516
- .replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}"></video>`) // show video urls as players
575
+ .replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}" crossorigin="anonymous"></audio>`) // show audio urls as players
576
+ .replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}" crossorigin="anonymous"></video>`) // show video urls as players
517
577
  .replace(/(?<!")https?:\/\/\S+/g, url => `<a href="${url}" target="yz.sidebar">${url}</a>`); // show urls as links
518
578
  let attachment = '';
519
579
  if (file?.startsWith?.('data:image')) attachment = `<a href="${file}" download="${name}"><img class="attachment" src="${file}"></img></a>`;
@@ -562,7 +622,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
562
622
  }
563
623
  startFader(selector, remaining) { // Set up or update fader on the specified marker element, returning that element.
564
624
  const { marker } = this;
565
- const element = marker.getElement().querySelector(selector);
625
+ const markerElement = marker.getElement();
626
+ if (!markerElement) return null; // removed (e.g., if expired).
627
+ const element = markerElement.querySelector(selector);
566
628
  const fraction = remaining / ttl; // Start at 1 and go to 0, but we may be some way along that.
567
629
  const endOpacity = 0.5; // Fully transparent is 0, but that's too hard to see. :-)
568
630
  const endGrayscale = 1; // Fully gray.
@@ -572,7 +634,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
572
634
  element.style.opacity = opacity;
573
635
  // I'd like to let css transitions do the work, but as we zoom, we make different subscriptions and thus start
574
636
  // the "same" marker over again. This initial setup clashes with zooming if done with a next-tick step opacity+filter value.
575
- const interval = 2e3; // Milliseconds / step
637
+ const interval = 10e3; // Milliseconds / step
576
638
  const opacityFade = (endOpacity - opacity) * interval / remaining; // change / step
577
639
  const grayscaleFade = (endGrayscale - grayscale) * interval / remaining;
578
640
  clearInterval(this[selector]);
@@ -593,3 +655,4 @@ export class Alert extends Conversation { // A wrapper around L.marker
593
655
  super.destroy();
594
656
  }
595
657
  }
658
+ globalThis.Alert = Alert; // for debugging