@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.
- package/index.js +2 -1
- package/package.json +5 -3
- package/public/about/en.html +1 -1
- package/public/about/es.html +1 -1
- package/public/images/loading.gif +0 -0
- package/public/index.html +106 -107
- package/public/javascripts/agent.js +37 -30
- package/public/javascripts/alert.js +112 -64
- package/public/javascripts/conversation.js +8 -5
- package/public/javascripts/hashtags.js +247 -43
- package/public/javascripts/main.js +6 -2
- package/public/javascripts/map.js +4 -2
- package/public/javascripts/p2pWebNetwork.js +31 -10
- package/public/javascripts/s2.js +7 -3
- package/public/javascripts/service-manager.js +5 -4
- package/public/javascripts/translations.js +1 -0
- package/public/javascripts/versions.js +16 -5
- package/public/service-worker.js +7 -7
- package/public/stylesheets/style.css +74 -12
- package/server/app.js +34 -23
- package/server/{location.json → location.save.json} +1 -1
- package/spec/axonSpec.js +9 -8
- package/spec/conversationSpec.js +2 -2
- package/public/junk.html +0 -145
|
@@ -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)
|
|
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
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
173
|
-
const periodStart = Date.now() - (maxPublish * 60e3); // maxPublish minutes ago.
|
|
174
|
-
|
|
175
|
-
if (cancel === undefined &&
|
|
176
|
-
showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period
|
|
177
|
-
cancel =
|
|
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 =
|
|
212
|
-
if (index >= 0)
|
|
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
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|