@yz-social/civildefense.io 4.5.29 → 5.0.3
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 +1 -0
- package/package.json +4 -2
- package/public/about/add-topic-autocomplete.png +0 -0
- package/public/about/add-topic.png +0 -0
- package/public/about/can-happen.jpg +0 -0
- package/public/about/en.html +72 -30
- package/public/about/identity.html +48 -0
- package/public/about/index.html +1 -1
- package/public/about/join.webp +0 -0
- package/public/about/notification.html +71 -0
- package/public/about/posters.jpeg +0 -0
- package/public/about/pwa.html +61 -0
- package/public/about/style.css +15 -2
- package/public/about/template.html +28 -0
- package/public/about/topics.png +0 -0
- package/public/about/train-radio-button.png +0 -0
- package/public/about/train.png +0 -0
- package/public/about/ukraine-fires.jpeg +0 -0
- package/public/about/ukraine-fires.png +0 -0
- package/public/index.html +17 -9
- package/public/javascripts/alert.js +203 -119
- package/public/javascripts/browser-push.js +56 -0
- package/public/javascripts/conversation.js +6 -3
- package/public/javascripts/display.js +2 -1
- package/public/javascripts/hashtags.js +3 -1
- package/public/javascripts/main.js +102 -64
- package/public/javascripts/map.js +13 -6
- package/public/javascripts/p2pWebNetwork.js +37 -4
- package/public/javascripts/protocol.js +3 -3
- package/public/javascripts/pubsub.js +107 -98
- package/public/javascripts/push.js +28 -0
- package/public/javascripts/s2.js +24 -2
- package/public/javascripts/service-manager.js +11 -7
- package/public/javascripts/store-memory.js +74 -0
- package/public/javascripts/store-redis.js +101 -0
- package/public/javascripts/translations.js +6 -6
- package/public/service-worker.js +50 -5
- package/public/stylesheets/style.css +50 -17
- package/server/websocket.js +12 -5
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import * as L from 'leaflet';
|
|
2
2
|
import { P2PWebNetwork } from './p2pWebNetwork.js';
|
|
3
|
+
import { generateVapidKeys } from './browser-push.js';
|
|
3
4
|
import { Int } from './translations.js';
|
|
4
5
|
import { map, trackMap, showMessage } from './map.js';
|
|
5
|
-
import { networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, getText, openAbout, delay, osName } from './main.js';
|
|
6
|
+
import { closeAll, postServiceMessage, networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, getText, openAbout, delay, osName, lastCells } from './main.js';
|
|
6
7
|
import { consume } from './display.js';
|
|
7
8
|
import { Hashtags } from './hashtags.js';
|
|
8
9
|
import { Agent } from './agent.js';
|
|
9
10
|
import { Conversation, Reply } from './conversation.js';
|
|
10
11
|
import { alertTopic, topicRegion, cellHex, topicCell, topicTag } from './versions.js';
|
|
11
|
-
import { getContainingCells, getSmallestCellId, getSubdivision, findCoverCellsByMinMaxLatLng, cellContains, pointFromLatLng, cellFromCellID } from './s2.js';
|
|
12
|
+
import { getContainingCells, getSmallestCellId, getSubdivision, findCoverCellsByMinMaxLatLng, cellContains, pointFromLatLng, cellFromCellID, getCellCorners } from './s2.js';
|
|
12
13
|
const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
|
|
13
14
|
|
|
14
15
|
|
|
@@ -43,7 +44,7 @@ export async function share(properties) { // Invoke platform share API on prope
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
if (!properties.files) {
|
|
46
|
-
|
|
47
|
+
closeAll();
|
|
47
48
|
await delay(500); // Allow popup time to close. It doesn't render well because of the web component style sheets.
|
|
48
49
|
const target = document.getElementById('mapCapture'); // For capturing a screen shot.
|
|
49
50
|
const icon = target.lastElementChild;
|
|
@@ -65,6 +66,7 @@ export async function share(properties) { // Invoke platform share API on prope
|
|
|
65
66
|
const ttl = 24 * 60 * 60e3; // 24 hours
|
|
66
67
|
let openOnReceive = null;
|
|
67
68
|
export function go({lat = null, lng = null, zoom = null, alert = null}) { // Go to specified location (if any) and open marker (if any).
|
|
69
|
+
closeAll();
|
|
68
70
|
if (lat !== null && lng !== null) {
|
|
69
71
|
lat = parseFloat(lat);
|
|
70
72
|
lng = parseFloat(lng);
|
|
@@ -91,7 +93,7 @@ class AlertReply extends Reply {
|
|
|
91
93
|
// and contents, and then have ensureContent() arrange in its delayed followup to replace the placeholder when the promise
|
|
92
94
|
// resolves.
|
|
93
95
|
await super.initialize(properties);
|
|
94
|
-
const {container, agent, issuedTime, payload} = properties;
|
|
96
|
+
const {container, agent, issuedTime, payload, alert} = properties;
|
|
95
97
|
const {file:attachmentTopic} = payload;
|
|
96
98
|
if (attachmentTopic) {
|
|
97
99
|
const contact = await networkPromise;
|
|
@@ -99,13 +101,30 @@ class AlertReply extends Reply {
|
|
|
99
101
|
const {dataURL:file, name, msgIds} = await contact.assembleChunkedDataURL(attachmentTopic);
|
|
100
102
|
Object.assign(payload, {file, name, attachmentTopic, msgIds});
|
|
101
103
|
}
|
|
102
|
-
|
|
104
|
+
this.showNotification();
|
|
103
105
|
return this;
|
|
104
106
|
}
|
|
107
|
+
get body() {
|
|
108
|
+
const {payload} = this;
|
|
109
|
+
const body = payload.message || payload.name || payload;
|
|
110
|
+
return body;
|
|
111
|
+
}
|
|
105
112
|
update() { } // TODO: are we really getting multiple reply events for the same data?
|
|
113
|
+
// We coalesce notifications by tags. If we did not, it would be nice to remove the
|
|
114
|
+
// notification if the reply is deleted.
|
|
115
|
+
// Since we do coelesce, that's largely unnecessary. However, there is still the case
|
|
116
|
+
// of the poster deleting the currently last reply. It would be nice to restore the
|
|
117
|
+
// previous message in the notification body. We COULD do that in a delete() method
|
|
118
|
+
// here, but that wouldn't work in a service worker for offline notifications, as it does
|
|
119
|
+
// not have access to the list of previous messages.
|
|
120
|
+
// So... for now, we do not remove alerts at all, because in the one case that it matters,
|
|
121
|
+
// it could result in a user becoming unaware of the remaining messages.
|
|
122
|
+
showNotification({force = false} = {}) {
|
|
123
|
+
const {container, agent, issuedTime, body, alert} = this;
|
|
124
|
+
container.showNotification({agent, issuedTime, body, alert, force});
|
|
125
|
+
}
|
|
106
126
|
}
|
|
107
127
|
|
|
108
|
-
import { s2 } from 's2js';
|
|
109
128
|
export class Alert extends Conversation { // A wrapper around L.marker
|
|
110
129
|
// For each hashtag, we subscribe to a set of non-overlapping cells at varying S2 levels that cover the current map.
|
|
111
130
|
// An Alert is made when a subscription handler fires, and it keeps information that is (mostly) true regardless of
|
|
@@ -150,22 +169,21 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
150
169
|
const marker = this.marker = L.marker([lat, lng], {icon, autoPan: false}).addTo(map);
|
|
151
170
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
152
171
|
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});
|
|
172
|
+
super.initialize({payload, hashtag, tag, agent, lat, lng, region, issuedTime, originalPosting, ...rest});
|
|
154
173
|
if (aggregate) {
|
|
155
174
|
// Destroy existing eventName markers and add their positions to the aggregate we are creating.
|
|
156
175
|
this.becomeAggregate(eventName);
|
|
157
176
|
this.constructor.clearEventMarkers(eventName, aggregate);
|
|
158
177
|
} else {
|
|
159
178
|
this.noteEventName(eventName);
|
|
160
|
-
marker.bindPopup('', {className: 'alert'})
|
|
179
|
+
marker.bindPopup('', {className: 'alert'})
|
|
180
|
+
.on('popupopen', event => this.ensureContent(event.popup))
|
|
181
|
+
.on('popupclose', () => this.closeTeach());
|
|
161
182
|
tooltip(marker.getElement(), Int`Show conversation for this ${hashtag} alert.`);
|
|
162
|
-
if (tag === openOnReceive) {
|
|
183
|
+
if (tag === openOnReceive) {
|
|
163
184
|
openOnReceive = false;
|
|
164
185
|
this.openPopup();
|
|
165
186
|
}
|
|
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
187
|
}
|
|
170
188
|
}
|
|
171
189
|
const alert = aggregate || this;
|
|
@@ -173,6 +191,17 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
173
191
|
alert.showNotification({agent, issuedTime});
|
|
174
192
|
return keep;
|
|
175
193
|
}
|
|
194
|
+
susbscribed = false;
|
|
195
|
+
async ensureRepliesSubscribed(unsubscribe = false) { // Listen for replies to this alert.
|
|
196
|
+
const {tag, region, aggregate, subscribed} = this;
|
|
197
|
+
console.log('subscribeReplies', {tag, region, aggregate, subscribed, unsubscribe});
|
|
198
|
+
if (aggregate) return;
|
|
199
|
+
this.subscribed = true;
|
|
200
|
+
const contact = await networkPromise;
|
|
201
|
+
if (unsubscribe) await contact.subscribe({eventName: tag, region, handler: null});
|
|
202
|
+
await contact.subscribe({eventName: tag, region, handler: data => this.ensure(data),
|
|
203
|
+
pushData: await this.constructor.pushData});
|
|
204
|
+
}
|
|
176
205
|
update({topic, ts, ...rest}) { // Called when handling an existing Conversation. super confirms that nothing immutable has changed.
|
|
177
206
|
return super.update({...rest}); // topic and ts vary with level, and so must not be part of ensure/update checks.
|
|
178
207
|
}
|
|
@@ -185,7 +214,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
185
214
|
this.clearAvatars();
|
|
186
215
|
const {isAggregate, marker, tag, region} = this;
|
|
187
216
|
// Unsubscribe from replies.
|
|
188
|
-
if (!isAggregate) networkPromise?.then(async contact =>
|
|
217
|
+
if (!isAggregate) networkPromise?.then(async contact => {
|
|
218
|
+
contact.subscribe({eventName: tag, region, handler: null});
|
|
219
|
+
});
|
|
189
220
|
this.cellBorder?.removeFrom(map);
|
|
190
221
|
super.destroy();
|
|
191
222
|
if (markerDelayMS) {
|
|
@@ -196,40 +227,105 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
196
227
|
}
|
|
197
228
|
marker.removeFrom(map);
|
|
198
229
|
}
|
|
230
|
+
delete() { // If ensure gets an empty body, cancel any associated notification.
|
|
231
|
+
if (notificationsAllowed()) postServiceMessage('cancelNotification', [this.tag]);
|
|
232
|
+
return super.delete();
|
|
233
|
+
}
|
|
199
234
|
|
|
200
|
-
static subscriptionQueue =
|
|
201
|
-
static
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
235
|
+
static subscriptionQueue = []; // Queue of pending updateSubscriptions reasons.
|
|
236
|
+
static updateSubscriptions(reason = 'map') { // Update current subscriptions for the given purpose:
|
|
237
|
+
// newNode: Supply an empty oldSubscriptions.
|
|
238
|
+
// stickyChange: Make sticky subscriptions match a new state.
|
|
239
|
+
// map: Make fewest changes for new map display.
|
|
240
|
+
//
|
|
241
|
+
// The effects must be serialized, but a pending map update can always be skipped for something after it.
|
|
242
|
+
// We don't want to stack up such changes, because the update can take several seconds on a bad network, during
|
|
243
|
+
// which the user might just keep futzing with the map.
|
|
244
|
+
const queue = this.subscriptionQueue;
|
|
245
|
+
const inProgress = queue.length;
|
|
246
|
+
queue.push(reason);
|
|
247
|
+
if (!inProgress) this.updatePendingSubscriptions();
|
|
248
|
+
}
|
|
249
|
+
static async updatePendingSubscriptions() { // Process the queue.
|
|
250
|
+
const contact = await networkPromise;
|
|
251
|
+
if (!contact) {
|
|
252
|
+
console.warn("No network through which to subscribe.");
|
|
253
|
+
return null; }
|
|
206
254
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
});
|
|
255
|
+
const queue = this.subscriptionQueue;
|
|
256
|
+
const reason = queue[0];
|
|
257
|
+
let newKeys, oldKeys;
|
|
258
|
+
if (reason === 'stickyChange') {
|
|
259
|
+
await contact.resetPersisted();
|
|
260
|
+
}
|
|
261
|
+
if (reason !== 'map') {
|
|
262
|
+
oldKeys = {};
|
|
263
|
+
// Update replies subscriptions for alerts that have already been opened.
|
|
264
|
+
for (const alert of this.items) {
|
|
265
|
+
if (alert.subscribed) await alert.ensureRepliesSubscribed(true);
|
|
229
266
|
}
|
|
267
|
+
} else if (queue.length > 1) { // Skip this map and just go on to whatever is pending.
|
|
268
|
+
queue.shift();
|
|
269
|
+
return this.updatePendingSubscriptions();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Now complete this subscription update in full, before considering any others.
|
|
273
|
+
oldKeys ||= this.subscriptions;
|
|
274
|
+
newKeys ||= this.subscriptionFromMap();
|
|
230
275
|
|
|
231
|
-
|
|
232
|
-
|
|
276
|
+
const dropped = [], added = [];
|
|
277
|
+
this.subscriptions = newKeys; // Before subscribing.
|
|
278
|
+
const subscribe = async (eventName, handler) => {
|
|
279
|
+
if (!eventName) console.log('sub to no eventName', {oldKeys, newKeys, dropped, added, handler});
|
|
280
|
+
const region = topicRegion(eventName);
|
|
281
|
+
const pushData = handler && await this.pushData;
|
|
282
|
+
if (handler) Agent.current?.trackPublicChanges(region); // Background. No need to await.
|
|
283
|
+
return contact.subscribe({eventName, region, handler, pushData, pushPersist: null});
|
|
284
|
+
};
|
|
285
|
+
for (const key in newKeys) oldKeys.hasOwnProperty(key) || added.push(key);
|
|
286
|
+
for (const key in oldKeys) newKeys.hasOwnProperty(key) || dropped.push(key);
|
|
287
|
+
console.log('updating subscriptions', {reason, added, dropped, newKeys, oldKeys});
|
|
288
|
+
|
|
289
|
+
// Before subscribing, as that that may bring in an alert with the same tag as one being cleared.
|
|
290
|
+
if (this.aggregateLimit) {
|
|
291
|
+
// TODO: more efficient way?
|
|
292
|
+
new Set(added.map(topicTag)).forEach(tag => {
|
|
293
|
+
const hasTag = topicName => topicTag(topicName) === tag;
|
|
294
|
+
this.transferOrClearEventMarkers(added.filter(hasTag), dropped.filter(hasTag), newKeys, oldKeys);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// Subtle: await throttle time between initiating network request, but do not wait for each request to complete before starting the next.
|
|
298
|
+
// Do wait for all to complete.
|
|
299
|
+
const throttleMS = 20;
|
|
300
|
+
const promises = [];
|
|
301
|
+
for (const key of added) {
|
|
302
|
+
promises.push(subscribe(key, data => Alert.ensure(data)));
|
|
303
|
+
if (throttleMS) await P2PWebNetwork.delay(throttleMS);
|
|
304
|
+
}
|
|
305
|
+
for (const key of dropped) {
|
|
306
|
+
promises.push(subscribe(key, null));
|
|
307
|
+
if (throttleMS) await P2PWebNetwork.delay(throttleMS);
|
|
308
|
+
}
|
|
309
|
+
contact.pushPersist();
|
|
310
|
+
await Promise.all(promises);
|
|
311
|
+
queue.shift();
|
|
312
|
+
//console.log('updated', promises.length);
|
|
313
|
+
if (queue.length) return this.updatePendingSubscriptions(); // Handle anything now pending.
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
static clearPushData() { // Force new push subscription when next asked. Not needed at startup, but when creating a new node.
|
|
317
|
+
this._pushData = null; // Otherwise, new subscriptions with the old data will be rejected by the service as being for an unsubscribed client.
|
|
318
|
+
}
|
|
319
|
+
static get pushData() {
|
|
320
|
+
if (!notificationsAllowed()) return null;
|
|
321
|
+
if (this._pushData) return this._pushData;
|
|
322
|
+
return this._pushData = new Promise(async resolve => {
|
|
323
|
+
const {publicKey:applicationServerKey, privateKey:applicationId} = await generateVapidKeys();
|
|
324
|
+
const registration = await navigator.serviceWorker.ready;
|
|
325
|
+
const data = await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey});
|
|
326
|
+
const json = data.toJSON();
|
|
327
|
+
//console.log('pushData', {registration, data, json});
|
|
328
|
+
resolve({...json, applicationId, applicationServerKey});
|
|
233
329
|
});
|
|
234
330
|
}
|
|
235
331
|
|
|
@@ -254,15 +350,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
254
350
|
this.eventName = eventName;
|
|
255
351
|
if (!this.isAggregate) return;
|
|
256
352
|
this.cellBorder?.removeFrom(map);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
});
|
|
264
|
-
const border = this.cellBorder = L.polygon(corners, {color: this.constructor.md_sys_color_secondary, fillOpacity: 0.1});
|
|
353
|
+
this.cellBorder = this.constructor.displayCell(topicCell(eventName));
|
|
354
|
+
}
|
|
355
|
+
static displayCell(cell, color = this.md_sys_color_secondary) { // And return cell for caching/removal.
|
|
356
|
+
// Draw polygon border from cell corners.
|
|
357
|
+
const corners = getCellCorners(cell);
|
|
358
|
+
const border = L.polygon(corners, {color, fillOpacity: 0.1});
|
|
265
359
|
border.addTo(map);
|
|
360
|
+
return border;
|
|
266
361
|
}
|
|
267
362
|
static md_sys_color_secondary = '#BD2E2F';
|
|
268
363
|
static forEachAlertOf(eventName, callback, alerts = this.items) { // Apply callback to each of alerts matching eventName.
|
|
@@ -394,7 +489,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
394
489
|
|
|
395
490
|
// We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
|
|
396
491
|
static lastLevel9Cell = null; // S2 level 9 cells average a radius of about 10km ~ 6.5 miles.
|
|
397
|
-
static
|
|
492
|
+
static shownCells = false; // Empty array for stashing debug polygons, else falsy.
|
|
493
|
+
static subscriptionFromMap() { // Generate new subscriptions list ({eventName => count}) for current map bounds,
|
|
494
|
+
// and additionally, a 1x1 degree box around the user's last known position, if any, regardless of map bounds.
|
|
398
495
|
const center = map.getCenter();
|
|
399
496
|
const bounds = map.getBounds();
|
|
400
497
|
const northEast = bounds.getNorthEast();
|
|
@@ -407,7 +504,15 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
407
504
|
minLng: southWest.lng,
|
|
408
505
|
maxLng: northEast.lng
|
|
409
506
|
});
|
|
410
|
-
if (!newCells) return
|
|
507
|
+
if (!newCells) return {};
|
|
508
|
+
if (this.shownCells) { // debugging
|
|
509
|
+
this.shownCells.forEach(polygon => polygon.removeFrom(map));
|
|
510
|
+
const mapPolys = newCells.map(cell => this.displayCell(cell));
|
|
511
|
+
const geoPolys = lastCells.map(cell => this.displayCell(cell, 'blue'));
|
|
512
|
+
this.shownCells = [...mapPolys, ...geoPolys];
|
|
513
|
+
}
|
|
514
|
+
// Alert.ensure will not pub multiple markers/notifications for the same alert, but let us not double subscriptions.
|
|
515
|
+
if (lastCells) lastCells.forEach(cell => newCells.includes(cell) || newCells.push(cell));
|
|
411
516
|
const newKeys = {};
|
|
412
517
|
newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => { // Populate count with existing count (exctly carried over cell sizes), else 0.
|
|
413
518
|
const eventName = alertTopic(cell, hash);
|
|
@@ -437,7 +542,6 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
437
542
|
payload = {lat, lng, originalPosting}, // If payload is null (cancels tag), lat & lng are still used to generate eventNames.
|
|
438
543
|
cancel = undefined, // First unpublish the specified data, if any. Complicated default.
|
|
439
544
|
issuedTime = Date.now(), tag,
|
|
440
|
-
throttleMS = 0,
|
|
441
545
|
...rest
|
|
442
546
|
}) {
|
|
443
547
|
// We call all the publishing at once and return tag, without waiting for each to occur.
|
|
@@ -457,14 +561,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
457
561
|
|
|
458
562
|
const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
|
|
459
563
|
let oldCells = null, oldHash, oldTag = null; // Recorded for logging, below.
|
|
460
|
-
let lastFillIn;
|
|
564
|
+
let lastFillIn = {lat, lng, hashtag, issuedTime};
|
|
461
565
|
if (payload) {
|
|
462
|
-
lastFillIn = {lat, lng, hashtag, issuedTime};
|
|
463
566
|
this.lastPublished.push(lastFillIn); // Capture the added data.
|
|
464
567
|
const periodStart = Date.now() - (this.maxPublish * 60e3); // maxPublish minutes ago.
|
|
465
568
|
this.lastPublished = this.lastPublished.filter(past => past.issuedTime >= periodStart);
|
|
466
569
|
if (cancel === undefined && this.lastPublished.length > this.maxPublish) { // Unless specified otherwise, cancel oldest over maxPublish.
|
|
467
|
-
showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period
|
|
570
|
+
setTimeout(() => showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.`),
|
|
571
|
+
3e3); // If immediate, it will get covered by instructions on the new alert.
|
|
468
572
|
cancel = this.lastPublished.shift();
|
|
469
573
|
}
|
|
470
574
|
}
|
|
@@ -473,36 +577,15 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
473
577
|
oldCells = getContainingCells(lat, lng);
|
|
474
578
|
oldHash = hashtag; oldTag = tag;
|
|
475
579
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
476
|
-
|
|
477
|
-
const eventName = alertTopic(cell, hashtag);
|
|
478
|
-
// Note: we cannot unpublish replies by others, but they expire after a while anyway.
|
|
479
|
-
await contact.publish({eventName, region, killTag: tag, payload: null});
|
|
480
|
-
throttleMS && await P2PWebNetwork.delay(throttleMS);
|
|
481
|
-
}
|
|
580
|
+
await Promise.all(oldCells.map(cell => contact.publish({eventName: alertTopic(cell, hashtag), region, killTag: tag, payload: null})));
|
|
482
581
|
}
|
|
483
582
|
|
|
484
583
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const msgId = await contact.publish({eventName, region, payload, issuedTime, hashtag, ...rest});
|
|
491
|
-
if (tag && tag !== msgId) throw new Error(`msgId is drifting: ${tag} => ${msgId}`);
|
|
492
|
-
tag = msgId;
|
|
493
|
-
if (lastFillIn) {
|
|
494
|
-
lastFillIn.tag = tag;
|
|
495
|
-
lastFillIn = null;
|
|
496
|
-
}
|
|
497
|
-
} else {
|
|
498
|
-
await contact.publish({eventName, region, killTag: tag, payload: null});
|
|
499
|
-
throttleMS && await P2PWebNetwork.delay(throttleMS);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
if (!payload) {
|
|
503
|
-
const index = this.lastPublished.findIndex(past => past.tag === tag);
|
|
504
|
-
if (index >= 0) this.lastPublished.splice(index, 1);
|
|
505
|
-
}
|
|
584
|
+
const pubs = await Promise.all(eventNames.map(eventName =>
|
|
585
|
+
contact.publish({eventName, region, killTag: tag, payload: tag ? null : payload, issuedTime, hashtag, ...rest})));
|
|
586
|
+
if (payload) tag = lastFillIn.tag = pubs[0];
|
|
587
|
+
else this.lastPublished = this.lastPublished.filter(past => past.tag !== tag);
|
|
588
|
+
|
|
506
589
|
console.log('Published', {cells, n: cells.length, region, hashtag, tag, payload, oldCells, oldHash, oldTag});
|
|
507
590
|
return tag;
|
|
508
591
|
} finally {
|
|
@@ -516,10 +599,11 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
516
599
|
|
|
517
600
|
static noMessage = Int`No additional information.`;
|
|
518
601
|
static closePopup() { // Close any open popup.
|
|
519
|
-
map
|
|
602
|
+
map?.closePopup();
|
|
520
603
|
Hashtags.closeSelector();
|
|
521
604
|
}
|
|
522
|
-
static openPopup(alertTag) { // Open the marker specified by tag.
|
|
605
|
+
static async openPopup(alertTag) { // Open the marker specified by tag.
|
|
606
|
+
await networkPromise;
|
|
523
607
|
const wrapper = this.getItem(alertTag);
|
|
524
608
|
wrapper?.openPopup() || (openOnReceive = alertTag);
|
|
525
609
|
}
|
|
@@ -561,10 +645,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
561
645
|
}
|
|
562
646
|
|
|
563
647
|
needsRedisplay = true;
|
|
564
|
-
ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
|
|
648
|
+
async ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
|
|
649
|
+
resetInactivityTimer();
|
|
565
650
|
if (!popup) return;
|
|
566
651
|
if (!popup.isOpen()) return;
|
|
567
652
|
this.logAlert();
|
|
653
|
+
if (!this.subscribed) await this.ensureRepliesSubscribed();
|
|
568
654
|
if (!this.needsRedisplay) {
|
|
569
655
|
this.initializeHandlers(popup);
|
|
570
656
|
return;
|
|
@@ -575,18 +661,27 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
575
661
|
let content = this.formatAttribution({agent, issuedTime, originalPosting, hashtag});
|
|
576
662
|
content += this.formatReplies();
|
|
577
663
|
popup.setContent(content);
|
|
578
|
-
|
|
579
|
-
this.marker.getPopup()
|
|
664
|
+
const onFirstNewPopup = () => {
|
|
665
|
+
const popup = this.marker.getPopup();
|
|
666
|
+
if (!popup) delay(50).then(onFirstNewPopup);
|
|
667
|
+
popup.update();
|
|
580
668
|
this.initializeHandlers(popup);
|
|
581
669
|
this.teach('firstConversation');
|
|
582
670
|
if (Agent.isMine(this.agent)) this.teach('firstPublish');
|
|
583
|
-
}
|
|
671
|
+
};
|
|
672
|
+
onFirstNewPopup();
|
|
584
673
|
}
|
|
585
674
|
teach(classname) {
|
|
586
675
|
if (localStorage.getItem(classname)) return;
|
|
587
676
|
localStorage.setItem(classname, '1');
|
|
588
677
|
document.body.classList.toggle(classname, true);
|
|
678
|
+
document.querySelectorAll('.teach').forEach(element => element.onclick = () => this.closeTeach());
|
|
679
|
+
}
|
|
680
|
+
closeTeach() { // All of them.
|
|
681
|
+
document.body.classList.toggle('firstConversation', false);
|
|
682
|
+
document.body.classList.toggle('firstPublish', false);
|
|
589
683
|
}
|
|
684
|
+
|
|
590
685
|
clearAvatars(popup = this.marker?.getPopup()) {
|
|
591
686
|
popup?.getElement()?.querySelectorAll('.correspondent[data-tag]')
|
|
592
687
|
.forEach(element => Agent.ensure({tag: element.dataset.tag}).removeElement(element, 'mixed', element.classList.contains('avatar') ? 'avatar' : 'handle'));
|
|
@@ -614,10 +709,10 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
614
709
|
filenameDisplay.textContent = fileChooser.files.length ? (fileChooser.files[0].name || 'camera') : '';
|
|
615
710
|
};
|
|
616
711
|
this.initChangeHashtag(popupElement);
|
|
712
|
+
const region = this.region;
|
|
617
713
|
for (const correspondent of popupElement.querySelectorAll('.correspondent')) {
|
|
618
714
|
const tag = correspondent.dataset.tag;
|
|
619
|
-
const
|
|
620
|
-
const agent = Agent.ensure({tag, region: region});
|
|
715
|
+
const agent = Agent.ensure({tag, region});
|
|
621
716
|
const isAvatar = correspondent.classList.contains('avatar');
|
|
622
717
|
if (agent.addElement(correspondent, 'mixed', isAvatar ? 'avatar' : 'handle')) {
|
|
623
718
|
const isMine = Agent.isMine(tag);
|
|
@@ -674,7 +769,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
674
769
|
`;
|
|
675
770
|
return `<md-outlined-button class="changeHashtag">${pubtag}</md-outlined-button>`;
|
|
676
771
|
}
|
|
677
|
-
formatAttributionActions({agent, hashtag}) { //
|
|
772
|
+
formatAttributionActions({agent, hashtag}) { // Answer div HTML containing: [deleter] sharer [hashtag]
|
|
678
773
|
// Where deletere appears if it our reply (no hashtag), and hashtag if present is a button if ours (and otherwise just text).
|
|
679
774
|
const isOurs = agent === Agent.tag;
|
|
680
775
|
const deleter = !hashtag && isOurs ? `<md-outlined-icon-button><md-icon class="material-icons">delete_forever</md-icon></md-outlined-icon-button>` : '';
|
|
@@ -728,8 +823,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
728
823
|
const ringElement = this.startFader('.alert-commented', remaining);
|
|
729
824
|
if (ringElement) {
|
|
730
825
|
ringElement.style.display = 'block';
|
|
731
|
-
|
|
732
|
-
ringElement.style.animationName = ringElement.style.animationName === 'pulse2' ? 'pulse' : 'pulse2';
|
|
826
|
+
ringElement.style.animationName = 'pulse20 ease 2s 1';
|
|
733
827
|
}
|
|
734
828
|
}
|
|
735
829
|
}
|
|
@@ -742,9 +836,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
742
836
|
event.stopPropagation();
|
|
743
837
|
const button = event.target;
|
|
744
838
|
const inputElement = button.parentElement;
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
839
|
+
const {tag, hashtag, lat, lng, region} = this;
|
|
840
|
+
let payload = {message: inputElement.value.trim(), lat, lng};
|
|
748
841
|
const files = inputElement.parentElement.querySelector('input[type="file"]').files;
|
|
749
842
|
if (!payload && !files.length) return;
|
|
750
843
|
inputElement.value = '';
|
|
@@ -752,15 +845,17 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
752
845
|
const contact = await networkPromise;
|
|
753
846
|
if (files.length) {
|
|
754
847
|
const {topic:file, msgIds} = await contact.chunkifyBlob({blob: files[0], region});
|
|
755
|
-
payload =
|
|
848
|
+
payload.file = file;
|
|
756
849
|
}
|
|
757
|
-
|
|
850
|
+
// Users won't subscribe to tag unless the alert itself has already landed, and that has lat/lng/hashtag.
|
|
851
|
+
// However, these must be included anyway in case the receiving user clicks on an out-of-band
|
|
852
|
+
// notification at a later time, without the alert being on the map at that moment.
|
|
853
|
+
await contact.publish({eventName: tag, region, payload, hashtag, alert: tag}); // Publish the new reply.
|
|
758
854
|
Agent.current.persistPublicMetadata();
|
|
759
855
|
}
|
|
760
856
|
deleteReply(replyElement) {
|
|
761
857
|
resetInactivityTimer();
|
|
762
|
-
const {lat, lng, tag} = this;
|
|
763
|
-
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
858
|
+
const {lat, lng, region, tag} = this;
|
|
764
859
|
const killTag = replyElement.dataset.tag;
|
|
765
860
|
networkPromise.then(async contact => {
|
|
766
861
|
// We won't be here unless we are the signer.
|
|
@@ -776,34 +871,23 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
776
871
|
}
|
|
777
872
|
});
|
|
778
873
|
}
|
|
779
|
-
showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
|
|
874
|
+
showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag, force = false}) {
|
|
780
875
|
// Give OS notification that comes back to here, unless act is us.
|
|
781
876
|
// All notifications on the same alert (e.g., the post and each reply) have the same tag, so OS can collapse them.
|
|
877
|
+
//console.log('alert showNotification', {lat, lng, hashtag, alert, agent, body, force});
|
|
782
878
|
if (agent === Agent.tag || !notificationsAllowed()) return;
|
|
783
|
-
|
|
784
|
-
const timestamp = issuedTime;
|
|
785
|
-
const icon = new URL('./images/civil-defense-192.png', location.href).href;
|
|
786
|
-
const url = getShareableURL(alert, [hashtag]).href; // For opening page when it has been closed.
|
|
787
|
-
const data = {lat, lng, url};
|
|
788
|
-
// It appears that on 8/14/26:
|
|
789
|
-
// Safari ignores tag/renotify, and ALWAYS tells the user and displays each notification separately, without consolidating by tag.
|
|
790
|
-
// Chrome ignores renotify, and ALWAYS consolidates by tag, replacing old body with new, and NEVER renotifies the user (for the same tag).
|
|
791
|
-
// 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.
|
|
792
|
-
const options = {icon, timestamp, tag: alert, body, data, renotify: true};
|
|
793
|
-
console.log('showNotification', hashtag, options);
|
|
794
|
-
registration.showNotification(hashtag, options);
|
|
795
|
-
});
|
|
879
|
+
postServiceMessage('notify', {lat, lng, issuedTime, hashtag, alert, body, force});
|
|
796
880
|
}
|
|
797
881
|
// Each reply element is a DIV.reply with data-tag and data-text attributes that are used in sharing.
|
|
798
882
|
// It contains an attribution header with controls, zero or one attachments, and then the message text.
|
|
799
883
|
// 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.
|
|
800
884
|
formatReplies() { // Answer HTML for the replies and input box.
|
|
801
885
|
const { items, agent, originalPosting } = this;
|
|
802
|
-
const formatReply = ({tag, payload, ...rest}) => {
|
|
886
|
+
const formatReply = ({tag, payload, hashtag, ...rest}) => {
|
|
803
887
|
const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
|
|
804
888
|
let text = message
|
|
805
|
-
.replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|
|
|
806
|
-
.replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}" crossorigin="anonymous"></video>`) // show video urls as players
|
|
889
|
+
.replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}" crossorigin="anonymous"></audio>`) // show audio urls as players
|
|
890
|
+
.replace(/https?:\/\/\S+\.(mp4|mov|webm|m3u8)$/ig, url => `<video controls src="${url}" crossorigin="anonymous"></video>`) // show video urls as players
|
|
807
891
|
.replace(/(?<!")https?:\/\/\S+/g, url => `<a href="${url}" target="yz.sidebar">${url}</a>`); // show urls as links
|
|
808
892
|
let attachment = '';
|
|
809
893
|
if (file?.startsWith?.('data:image')) attachment = `<a href="${file}" download="${name}"><img class="attachment" src="${file}"></img></a>`;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
function bufferToBase64Url(buffer) { // Like it says.
|
|
2
|
+
const bytes = new Uint8Array(buffer);
|
|
3
|
+
let binary = '';
|
|
4
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
5
|
+
binary += String.fromCharCode(bytes[i]);
|
|
6
|
+
}
|
|
7
|
+
return btoa(binary)
|
|
8
|
+
.replace(/\+/g, '-')
|
|
9
|
+
.replace(/\//g, '_')
|
|
10
|
+
.replace(/=+$/, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// These are the keys used by sender to authenticate itself to the push service.
|
|
14
|
+
// The pubkey is given by the application to the browser pushManager.subscribe() so that the push-service knows.
|
|
15
|
+
// Then the entity sending the push signs the request that it makes to the push-service.
|
|
16
|
+
//
|
|
17
|
+
// The browser only allows ONE current subscribe in the page, with one such pubkey.
|
|
18
|
+
// I.e., a page cannot have multiple subscriptions to different services simultaneously.
|
|
19
|
+
//
|
|
20
|
+
// In server-sovereign applications, the server makes the key pair only once (for at least the lifetime of an app version),
|
|
21
|
+
// and passes the pubkey to the app.
|
|
22
|
+
//
|
|
23
|
+
// In our client-sovereign applications, each Axon root is essentially its own appserver in that it makes the requests
|
|
24
|
+
// to the push-service. In order to have just one subscription per client, we have the client generate the keypair,
|
|
25
|
+
// use the pubkey in the normal way when makeing the pushManger.subscribe() call, and pass the keys to Axon node for
|
|
26
|
+
// use when pushing to this particular client. It will use other keys when pushing to other clients.
|
|
27
|
+
// (The client still authenticates the pushed envelope in the normal way, just as if it had been delivered directly
|
|
28
|
+
// through the connected Axona network.)
|
|
29
|
+
|
|
30
|
+
export async function generateVapidKeys() {
|
|
31
|
+
// 1. Generate an ECDSA key pair on the P-256 curve
|
|
32
|
+
const keyPair = await window.crypto.subtle.generateKey(
|
|
33
|
+
{
|
|
34
|
+
name: 'ECDSA',
|
|
35
|
+
namedCurve: 'P-256',
|
|
36
|
+
},
|
|
37
|
+
true, // extractable
|
|
38
|
+
['sign', 'verify']
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// 2. Export public key in 'raw' format (65 bytes: 0x04 + 32-byte X + 32-byte Y)
|
|
42
|
+
const rawPublicKey = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
|
|
43
|
+
const publicKeyBase64Url = bufferToBase64Url(rawPublicKey);
|
|
44
|
+
|
|
45
|
+
// 3. Export private key in 'jwk' format to extract the 32-byte 'd' parameter
|
|
46
|
+
const jwkPrivateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
47
|
+
|
|
48
|
+
// The 'd' field in JWK is base64url encoded already; decode it to bytes then re-encode safely
|
|
49
|
+
const privateKeyBytes = Uint8Array.from(atob(jwkPrivateKey.d.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
|
|
50
|
+
const privateKeyBase64Url = bufferToBase64Url(privateKeyBytes);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
publicKey: publicKeyBase64Url, // 65 bytes decoded
|
|
54
|
+
privateKey: privateKeyBase64Url // 32 bytes decoded
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -23,23 +23,26 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
|
|
|
23
23
|
}
|
|
24
24
|
return this;
|
|
25
25
|
}
|
|
26
|
-
destroy() { // Subclasses extend to remove UI.
|
|
26
|
+
destroy() { // Subclasses extend to remove UI. Clients may call this directly. (Compare delete.)
|
|
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
30
|
return null;
|
|
31
31
|
}
|
|
32
|
+
delete() { // Form of destroy called by ensure() with no payload.
|
|
33
|
+
return this.destroy(); // Hook for recognizing that an item is "un-ensured" rather than destroyed directly.
|
|
34
|
+
}
|
|
32
35
|
|
|
33
36
|
static async ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
|
|
34
37
|
const {tag, payload, ...rest} = data;
|
|
35
38
|
let item = container.getItem(tag); // existing item
|
|
36
|
-
if (!payload) return item?.
|
|
39
|
+
if (!payload) return item?.delete();
|
|
37
40
|
|
|
38
41
|
// update or create item.
|
|
39
42
|
if (item) item = await item.update(data);
|
|
40
43
|
else item = await (new kind().initialize({...data, container}));
|
|
41
44
|
|
|
42
|
-
if (!item) return container.removeItem(tag)?.
|
|
45
|
+
if (!item) return container.removeItem(tag)?.delete();
|
|
43
46
|
return container.setItem(item.tag, item); // initialize() may come up with a better tag.
|
|
44
47
|
}
|
|
45
48
|
}
|