@yz-social/civildefense.io 4.5.8 → 4.5.16

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 } from './versions.js';
11
- import { getContainingCells, findCoverCellsByCenterAndPoint } from './s2.js';
10
+ import { alertTopic, topicRegion, topicCell } from './versions.js';
11
+ import { getContainingCells, getSubdivision, findCoverCellsByCenterAndPoint } 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);
@@ -42,7 +42,7 @@ export async function share(properties) { // Invoke platform share API on prope
42
42
  if (!properties.files) {
43
43
  Alert.closePopup();
44
44
  await delay(500); // Allow popup time to close. It doesn't render well because of the web component style sheets.
45
- const target = document.getElementById('mapCapture');
45
+ const target = document.getElementById('mapCapture'); // For capturing a screen shot.
46
46
  const icon = target.lastElementChild;
47
47
  const subPopoverControls = document.getElementById('subPopoverControls');
48
48
  const leafletControls = document.querySelector('.leaflet-control-container');
@@ -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
 
@@ -85,7 +85,7 @@ class AlertReply extends Reply {
85
85
  // a promise to reassemble the attachment, have formatReply create a findable placeholder to later replace with the A element
86
86
  // and contents, and then have ensureContent() arrange in its delayed followup to replace the placeholder when the promise
87
87
  // resolves.
88
- super.initialize(properties);
88
+ await super.initialize(properties);
89
89
  const {container, agent, issuedTime, payload} = properties;
90
90
  const {file:attachmentTopic} = payload;
91
91
  if (attachmentTopic) {
@@ -94,58 +94,85 @@ 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}));
138
-
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 newCells = findCoverCellsByCenterAndPoint(center.lat, center.lng, northEast.lat, northEast.lng); // array of cell IDs (BigInts)
116
+ if (!newCells) return null;
117
+ const region = P2PWebNetwork.regionCode(center.lat, center.lng);
118
+ const newKeys = {};
119
+ newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => {
120
+ const eventName = alertTopic(cell, hash);
121
+ newKeys[eventName] = this.subscriptions[eventName] || 0;
122
+ }));
123
+ Agent.current?.trackPublicChanges(region);
124
+ // Record a zoomed-out cell id in case next session does not have geolocation services.
125
+ let level9Cell = getContainingCells(center.lat, center.lng)[9];
126
+ if (level9Cell !== this.lastLevel9Cell) localStorage.setItem('level9Cell', this.lastLevel9Cell = level9Cell);
127
+ return newKeys;
128
+ }
129
+ static async updateSubscriptions({
130
+ oldKeys = this.subscriptions,
131
+ newKeys = this.subscriptionFromMap(),
132
+ throttleMS = 20
133
+ } = {}) { // Update current subscriptions.
134
+ // A value of {} passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
135
+ if (!newKeys) return; // e.g., wacky computation. Don't change anything.
136
+ const contact = await networkPromise;
137
+ if (!contact) { console.warn("No network through which to subscribe."); return; } // Does this ever happen? Why?
138
+ this.subscriptions = newKeys; // Before subscribing.
139
+ const subscribe = async (key, handlerIn) => {
140
+ const handler = handlerIn && (properties => {
141
+ // When there are enough alerts within a topic, it rolls over and just reports the most recent number.
142
+ // When get near that count, we update the subscriptions such that the overloading topic is replaced
143
+ // with the topics for each of the four subcells that that make up the one with too many alerts.
144
+ // This repeats until we reach cells that are not rolling over.
145
+ const maxAlertsInCell = 900;
146
+ let count = ++newKeys[key];
147
+ handlerIn(properties);
148
+ if (count >= maxAlertsInCell) {
149
+ let nextKeys = {};
150
+ for (const old in newKeys) {
151
+ if (old !== key) {
152
+ nextKeys[old] = newKeys[old];
153
+ } else {
154
+ const cellTag = topicCell(key);
155
+ const subdivisions = getSubdivision(cellTag);
156
+ const topics = subdivisions.map(cellTag => alertTopic(cellTag, properties.hashtag));
157
+ console.log('subdividing', key, 'into', topics);
158
+ topics.forEach(topic => nextKeys[topic] = 0);
159
+ }
160
+ }
161
+ this.updateSubscriptions({oldKeys: newKeys, newKeys: nextKeys, throttleMS});
162
+ }
163
+ });
164
+ const region = topicRegion(key);
165
+ await contact.subscribe({eventName: key, region, handler}).then(() => throttleMS && P2PWebNetwork.delay(throttleMS));
166
+ };
167
+ console.log('updating subscriptions', {newKeys, oldKeys});
139
168
  // 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
-
169
+ for (const key in newKeys) oldKeys.hasOwnProperty(key) || await subscribe(key, data => Alert.ensure(data));
142
170
  // 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;
171
+ for (const key in oldKeys) newKeys.hasOwnProperty(key) || await subscribe(key, null);
148
172
  }
173
+ static maxPublish = 5;
174
+ static publishing = false;
175
+ static lastPublished = []; // Last published lat, lng, tag
149
176
  // Publish an alert to all applicable eventNames, canceling as required. Promises tag (msgId).
150
177
  static async publish({lat, lng,
151
178
  originalPosting = undefined,
@@ -160,21 +187,21 @@ export class Alert extends Conversation { // A wrapper around L.marker
160
187
  // However, the 'unpublishing' (if any) is invoked first.
161
188
  // To do this, we must hash the eventName ourselves.
162
189
  //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.
190
+ if (this.publishing) { console.log('skiping overlapping publish'); return null; } // do not stack them up.
164
191
  try {
165
- publishing = true;
192
+ this.publishing = true;
166
193
 
167
194
  const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
168
195
  let oldCells = null, oldHash, oldSubject = null; // Recorded for logging, below.
169
196
  let lastFillIn;
170
197
  if (payload) {
171
198
  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();
199
+ this.lastPublished.push(lastFillIn); // Capture the added data.
200
+ const periodStart = Date.now() - (this.maxPublish * 60e3); // maxPublish minutes ago.
201
+ this.lastPublished = this.lastPublished.filter(past => past.issuedTime >= periodStart);
202
+ if (cancel === undefined && this.lastPublished.length > this.maxPublish) { // Unless specified otherwise, cancel oldest over maxPublish.
203
+ showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.`, 'instructions');
204
+ cancel = this.lastPublished.shift();
178
205
  }
179
206
  }
180
207
  if (cancel) {
@@ -208,23 +235,24 @@ export class Alert extends Conversation { // A wrapper around L.marker
208
235
  }
209
236
  }
210
237
  if (!payload) {
211
- const index = last.findIndex(past => past.subject === subject);
212
- if (index >= 0) last.splice(index, 1);
238
+ const index = this.lastPublished.findIndex(past => past.subject === subject);
239
+ if (index >= 0) this.lastPublished.splice(index, 1);
213
240
  }
214
241
  console.log('Published', {cells, n: cells.length, region, hashtag, subject, payload, oldCells, oldHash, oldSubject});
215
242
  return subject;
216
243
  } finally {
217
- publishing = false;
244
+ this.publishing = false;
218
245
  }
219
246
  }
220
247
 
221
248
  static noMessage = Int`No additional information.`;
222
249
  static closePopup() { // Close any open popup.
223
250
  map.closePopup();
251
+ Hashtags.closeSelector();
224
252
  }
225
253
  static openPopup(alertTag) { // Open the marker specified by subject.
226
254
  const wrapper = this.getItem(alertTag);
227
- wrapper?.openPopup();
255
+ wrapper?.openPopup() || (openOnReceive = alertTag);
228
256
  }
229
257
  async openPopup() { // Open this wrapper's popup, and resolve any waiting promise.
230
258
  const { resolveGo } = this; // A handy hook for scripting.
@@ -235,7 +263,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
235
263
  }
236
264
  this.marker.openPopup();
237
265
  }
238
- static makeIcon(hashtag) { // Return a Leaflet icon
266
+ static makeIcon(hashtag) { // Return a Leaflet icon. TODO: are these cacheable and reusable?
239
267
  return L.divIcon({
240
268
  html: `<div class="alert-commented"></div><div class="alert-pin">${Hashtags.formatAlert(hashtag)}</div>`,
241
269
  iconSize: [40, 40],
@@ -443,9 +471,26 @@ export class Alert extends Conversation { // A wrapper around L.marker
443
471
  get itemKind() { // Answer class of reply items.
444
472
  return AlertReply;
445
473
  }
446
- async ensure(data) { // Add or update reply for this marker.
474
+ async ensure(data) { // Add or update reply for this reply.
447
475
  data.subject = data.tag; //fixme
476
+ const remaining = data.issuedTime + ttl - Date.now();
477
+ if (remaining < 0) return null;
448
478
  const reply = await super.ensure(data);
479
+ if (reply) {
480
+ if (reply === this.items[0]) { // If first sorted reply, and there's a message, update the tooltip.
481
+ const message = reply.payload?.message || (!reply.payload.file && reply.payload);
482
+ const markerElement = message && this.marker.getElement();
483
+ if (markerElement) tooltip(markerElement, message);
484
+ }
485
+ if (reply === this.items[this.items.length - 1]) { // If last reply so far (even if first), show ring and update fader.
486
+ const ringElement = this.startFader('.alert-commented', remaining);
487
+ if (ringElement) {
488
+ ringElement.style.display = 'block';
489
+ // Restart the pulse animation by setting animationName to something it isn't.
490
+ ringElement.style.animationName = ringElement.style.animationName === 'pulse2' ? 'pulse' : 'pulse2';
491
+ }
492
+ }
493
+ }
449
494
  this.needsRedisplay = true;
450
495
  this.ensureContent();
451
496
  return reply;
@@ -472,12 +517,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
472
517
  }
473
518
  deleteReply(replyElement) {
474
519
  resetInactivityTimer();
475
- const {lat, lng} = this;
520
+ const {lat, lng, subject} = this;
476
521
  const region = P2PWebNetwork.regionCode(lat, lng);
477
522
  const killTag = replyElement.dataset.subject;
478
523
  networkPromise.then(async contact => {
479
524
  // We won't be here unless we are the signer.
480
- await contact.publish({eventName: this.subject, region, killTag, payload: null});
525
+ await contact.publish({eventName: subject, region, killTag, payload: null});
481
526
  // IFF there's an attachment AND we're given msgIds by receiveChunkedBytes, then delete the attachment.
482
527
  const reply = this.getItem(killTag);
483
528
  const {attachmentTopic, msgIds = []} = reply?.payload || {};
@@ -561,7 +606,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
561
606
  }
562
607
  startFader(selector, remaining) { // Set up or update fader on the specified marker element, returning that element.
563
608
  const { marker } = this;
564
- const element = marker.getElement().querySelector(selector);
609
+ const markerElement = marker.getElement();
610
+ if (!markerElement) return null; // removed (e.g., if expired).
611
+ const element = markerElement.querySelector(selector);
565
612
  const fraction = remaining / ttl; // Start at 1 and go to 0, but we may be some way along that.
566
613
  const endOpacity = 0.5; // Fully transparent is 0, but that's too hard to see. :-)
567
614
  const endGrayscale = 1; // Fully gray.
@@ -571,7 +618,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
571
618
  element.style.opacity = opacity;
572
619
  // I'd like to let css transitions do the work, but as we zoom, we make different subscriptions and thus start
573
620
  // the "same" marker over again. This initial setup clashes with zooming if done with a next-tick step opacity+filter value.
574
- const interval = 2e3; // Milliseconds / step
621
+ const interval = 10e3; // Milliseconds / step
575
622
  const opacityFade = (endOpacity - opacity) * interval / remaining; // change / step
576
623
  const grayscaleFade = (endGrayscale - grayscale) * interval / remaining;
577
624
  clearInterval(this[selector]);
@@ -592,3 +639,4 @@ export class Alert extends Conversation { // A wrapper around L.marker
592
639
  super.destroy();
593
640
  }
594
641
  }
642
+ globalThis.Alert = Alert; // for debugging
@@ -26,13 +26,14 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
26
26
  destroy() { // Subclasses extend to remove UI.
27
27
  // Subclass extensions of ensure may expect this answer falsy, indicating that an item was removed.
28
28
  // NOTE: Does not destroy replies, as these may have been published by others.
29
- return null;
29
+ this.container.removeItem(this.tag);
30
+ return null;
30
31
  }
31
32
 
32
33
  static async ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
33
34
  const {tag, payload, ...rest} = data;
34
35
  let item = container.getItem(tag);
35
- if (!payload) return item && container.removeItem(tag)?.destroy();
36
+ if (!payload) return item?.destroy();
36
37
  if (item) item = await item.update(data);
37
38
  else item = await (new kind().initialize({...data, container}));
38
39
 
@@ -59,8 +60,10 @@ export class Conversation extends Tagged { // A conversation with replies.
59
60
  return item;
60
61
  }
61
62
  removeItem(tag) { // Remove reply from cache.
62
- const { items } = this;
63
- return items.splice(items.findIndex(reply => reply.tag === tag), 1)?.[0];
63
+ const { items } = this;
64
+ const index = items.findIndex(reply => reply.tag === tag);
65
+ if (index < 0) return null;
66
+ return items.splice(index, 1)?.[0];
64
67
  }
65
68
  get itemKind() { // Answer class of reply items.
66
69
  return Reply;
@@ -76,7 +79,7 @@ export class Conversation extends Tagged { // A conversation with replies.
76
79
  if (!Object.hasOwn(this, '_conversations')) this._conversations = {};
77
80
  return this._conversations;
78
81
  }
79
- static items() {
82
+ static get items() {
80
83
  return Object.values(this.conversations);
81
84
  }
82
85
  static getItem(tag) { // Get conversation if known, else falsy.