@yz-social/civildefense.io 4.5.28 → 5.0.0
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/en.html +42 -9
- package/public/about/identity.html +33 -0
- package/public/about/index.html +1 -1
- package/public/about/notification.html +33 -0
- package/public/about/pwa.html +57 -0
- package/public/about/style.css +6 -0
- package/public/about/template.html +28 -0
- package/public/about/topics.png +0 -0
- package/public/index.html +17 -9
- package/public/javascripts/alert.js +140 -84
- 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 +2 -1
- package/public/javascripts/main.js +94 -60
- package/public/javascripts/map.js +5 -3
- 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 +10 -1
- package/server/getLocation.js +2 -2
- package/server/websocket.js +8 -4
|
@@ -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,11 +227,21 @@ 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
235
|
static subscriptionQueue = Promise.resolve(); // Serialize updates so they don't overlap each other.
|
|
201
236
|
static async updateSubscriptions({newKeys, oldKeys, throttleMS = 20} = {}) { // Update current subscriptions.
|
|
202
237
|
// A value of {} passed for oldKeys is used to start things off fresh (i.e., without supressing subscription of any carry-overs).
|
|
203
238
|
return this.subscriptionQueue = this.subscriptionQueue.then(async () => {
|
|
239
|
+
if (oldKeys && !Object.keys(oldKeys).length) { // This was a reset for a new node.
|
|
240
|
+
// Update replies subscriptions for alert that have already been opened.
|
|
241
|
+
for (const alert of this.items) {
|
|
242
|
+
if (alert.subscribed) await alert.ensureRepliesSubscribed(true);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
204
245
|
oldKeys ||= this.subscriptions;
|
|
205
246
|
newKeys ||= this.subscriptionFromMap();
|
|
206
247
|
|
|
@@ -212,8 +253,13 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
212
253
|
const subscribe = async (eventName, handler) => {
|
|
213
254
|
if (!eventName) console.log('sub to no eventName', {oldKeys, newKeys, dropped, added, handler});
|
|
214
255
|
const region = topicRegion(eventName);
|
|
256
|
+
const pushData = handler && await this.pushData;
|
|
215
257
|
if (handler) Agent.current?.trackPublicChanges(region); // Background. No need to await.
|
|
216
|
-
await contact.subscribe({eventName, region, handler
|
|
258
|
+
return await contact.subscribe({eventName, region, handler, pushData, pushPersist: null})
|
|
259
|
+
.then(sub => {
|
|
260
|
+
throttleMS && P2PWebNetwork.delay(throttleMS);
|
|
261
|
+
return sub;
|
|
262
|
+
});
|
|
217
263
|
};
|
|
218
264
|
for (const key in newKeys) oldKeys.hasOwnProperty(key) || added.push(key);
|
|
219
265
|
for (const key in oldKeys) newKeys.hasOwnProperty(key) || dropped.push(key);
|
|
@@ -230,8 +276,29 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
230
276
|
|
|
231
277
|
for (const key of added) await subscribe(key, data => Alert.ensure(data));
|
|
232
278
|
for (const key of dropped) await subscribe(key, null);
|
|
279
|
+
contact.pushPersist();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
static clearPushData() { // Force new push subscription when next asked. Not needed at startup, but when creating a new node.
|
|
283
|
+
this._pushData = null; // Otherwise, new subscriptions with the old data will be rejected by the service as being for an unsubscribed client.
|
|
284
|
+
}
|
|
285
|
+
static get pushData() {
|
|
286
|
+
if (!notificationsAllowed()) return null;
|
|
287
|
+
if (this._pushData) return this._pushData;
|
|
288
|
+
return this._pushData = new Promise(async resolve => {
|
|
289
|
+
const {publicKey:applicationServerKey, privateKey:applicationId} = await generateVapidKeys();
|
|
290
|
+
const registration = await navigator.serviceWorker.ready;
|
|
291
|
+
const data = await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey});
|
|
292
|
+
const json = data.toJSON();
|
|
293
|
+
//console.log('pushData', {registration, data, json});
|
|
294
|
+
resolve({...json, applicationId, applicationServerKey});
|
|
233
295
|
});
|
|
234
296
|
}
|
|
297
|
+
static async refreshPushSubscriptions() { // Make sticky subscriptions match a new state.
|
|
298
|
+
const contact = await networkPromise;
|
|
299
|
+
await contact.resetPersisted();
|
|
300
|
+
await this.updateSubscriptions({newKeys: this.subscriptions, oldKeys: {}});
|
|
301
|
+
}
|
|
235
302
|
|
|
236
303
|
// Instance Management Internals
|
|
237
304
|
// In general here, a key is an eventName - i.e., a string <mumble>:<cellID>:<hashtag>.
|
|
@@ -254,15 +321,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
254
321
|
this.eventName = eventName;
|
|
255
322
|
if (!this.isAggregate) return;
|
|
256
323
|
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});
|
|
324
|
+
this.cellBorder = this.constructor.displayCell(topicCell(eventName));
|
|
325
|
+
}
|
|
326
|
+
static displayCell(cell, color = this.md_sys_color_secondary) { // And return cell for caching/removal.
|
|
327
|
+
// Draw polygon border from cell corners.
|
|
328
|
+
const corners = getCellCorners(cell);
|
|
329
|
+
const border = L.polygon(corners, {color, fillOpacity: 0.1});
|
|
265
330
|
border.addTo(map);
|
|
331
|
+
return border;
|
|
266
332
|
}
|
|
267
333
|
static md_sys_color_secondary = '#BD2E2F';
|
|
268
334
|
static forEachAlertOf(eventName, callback, alerts = this.items) { // Apply callback to each of alerts matching eventName.
|
|
@@ -394,7 +460,9 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
394
460
|
|
|
395
461
|
// We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
|
|
396
462
|
static lastLevel9Cell = null; // S2 level 9 cells average a radius of about 10km ~ 6.5 miles.
|
|
397
|
-
static
|
|
463
|
+
static shownCells = false; // Empty array for stashing debug polygons, else falsy.
|
|
464
|
+
static subscriptionFromMap() { // Generate new subscriptions list ({eventName => count}) for current map bounds,
|
|
465
|
+
// and additionally, a 1x1 degree box around the user's last known position, if any, regardless of map bounds.
|
|
398
466
|
const center = map.getCenter();
|
|
399
467
|
const bounds = map.getBounds();
|
|
400
468
|
const northEast = bounds.getNorthEast();
|
|
@@ -408,6 +476,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
408
476
|
maxLng: northEast.lng
|
|
409
477
|
});
|
|
410
478
|
if (!newCells) return null;
|
|
479
|
+
if (this.shownCells) { // debugging
|
|
480
|
+
this.shownCells.forEach(polygon => polygon.removeFrom(map));
|
|
481
|
+
const mapPolys = newCells.map(cell => this.displayCell(cell));
|
|
482
|
+
const geoPolys = lastCells.map(cell => this.displayCell(cell, 'blue'));
|
|
483
|
+
this.shownCells = [...mapPolys, ...geoPolys];
|
|
484
|
+
}
|
|
485
|
+
// Alert.ensure will not pub multiple markers/notifications for the same alert, but let us not double subscriptions.
|
|
486
|
+
if (lastCells) lastCells.forEach(cell => newCells.includes(cell) || newCells.push(cell));
|
|
411
487
|
const newKeys = {};
|
|
412
488
|
newCells.forEach(cell => Hashtags.getSubscribe().forEach(hash => { // Populate count with existing count (exctly carried over cell sizes), else 0.
|
|
413
489
|
const eventName = alertTopic(cell, hash);
|
|
@@ -437,7 +513,6 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
437
513
|
payload = {lat, lng, originalPosting}, // If payload is null (cancels tag), lat & lng are still used to generate eventNames.
|
|
438
514
|
cancel = undefined, // First unpublish the specified data, if any. Complicated default.
|
|
439
515
|
issuedTime = Date.now(), tag,
|
|
440
|
-
throttleMS = 0,
|
|
441
516
|
...rest
|
|
442
517
|
}) {
|
|
443
518
|
// We call all the publishing at once and return tag, without waiting for each to occur.
|
|
@@ -457,14 +532,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
457
532
|
|
|
458
533
|
const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
|
|
459
534
|
let oldCells = null, oldHash, oldTag = null; // Recorded for logging, below.
|
|
460
|
-
let lastFillIn;
|
|
535
|
+
let lastFillIn = {lat, lng, hashtag, issuedTime};
|
|
461
536
|
if (payload) {
|
|
462
|
-
lastFillIn = {lat, lng, hashtag, issuedTime};
|
|
463
537
|
this.lastPublished.push(lastFillIn); // Capture the added data.
|
|
464
538
|
const periodStart = Date.now() - (this.maxPublish * 60e3); // maxPublish minutes ago.
|
|
465
539
|
this.lastPublished = this.lastPublished.filter(past => past.issuedTime >= periodStart);
|
|
466
540
|
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
|
|
541
|
+
setTimeout(() => showMessage(Int`Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.`),
|
|
542
|
+
3e3); // If immediate, it will get covered by instructions on the new alert.
|
|
468
543
|
cancel = this.lastPublished.shift();
|
|
469
544
|
}
|
|
470
545
|
}
|
|
@@ -473,36 +548,15 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
473
548
|
oldCells = getContainingCells(lat, lng);
|
|
474
549
|
oldHash = hashtag; oldTag = tag;
|
|
475
550
|
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
|
-
}
|
|
551
|
+
await Promise.all(oldCells.map(cell => contact.publish({eventName: alertTopic(cell, hashtag), region, killTag: tag, payload: null})));
|
|
482
552
|
}
|
|
483
553
|
|
|
484
554
|
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
|
-
}
|
|
555
|
+
const pubs = await Promise.all(eventNames.map(eventName =>
|
|
556
|
+
contact.publish({eventName, region, killTag: tag, payload: tag ? null : payload, issuedTime, hashtag, ...rest})));
|
|
557
|
+
if (payload) tag = lastFillIn.tag = pubs[0];
|
|
558
|
+
else this.lastPublished = this.lastPublished.filter(past => past.tag !== tag);
|
|
559
|
+
|
|
506
560
|
console.log('Published', {cells, n: cells.length, region, hashtag, tag, payload, oldCells, oldHash, oldTag});
|
|
507
561
|
return tag;
|
|
508
562
|
} finally {
|
|
@@ -519,7 +573,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
519
573
|
map.closePopup();
|
|
520
574
|
Hashtags.closeSelector();
|
|
521
575
|
}
|
|
522
|
-
static openPopup(alertTag) { // Open the marker specified by tag.
|
|
576
|
+
static async openPopup(alertTag) { // Open the marker specified by tag.
|
|
577
|
+
await networkPromise;
|
|
523
578
|
const wrapper = this.getItem(alertTag);
|
|
524
579
|
wrapper?.openPopup() || (openOnReceive = alertTag);
|
|
525
580
|
}
|
|
@@ -561,10 +616,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
561
616
|
}
|
|
562
617
|
|
|
563
618
|
needsRedisplay = true;
|
|
564
|
-
ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
|
|
619
|
+
async ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
|
|
620
|
+
resetInactivityTimer();
|
|
565
621
|
if (!popup) return;
|
|
566
622
|
if (!popup.isOpen()) return;
|
|
567
623
|
this.logAlert();
|
|
624
|
+
if (!this.subscribed) await this.ensureRepliesSubscribed();
|
|
568
625
|
if (!this.needsRedisplay) {
|
|
569
626
|
this.initializeHandlers(popup);
|
|
570
627
|
return;
|
|
@@ -575,18 +632,27 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
575
632
|
let content = this.formatAttribution({agent, issuedTime, originalPosting, hashtag});
|
|
576
633
|
content += this.formatReplies();
|
|
577
634
|
popup.setContent(content);
|
|
578
|
-
|
|
579
|
-
this.marker.getPopup()
|
|
635
|
+
const onFirstNewPopup = () => {
|
|
636
|
+
const popup = this.marker.getPopup();
|
|
637
|
+
if (!popup) delay(50).then(onFirstNewPopup);
|
|
638
|
+
popup.update();
|
|
580
639
|
this.initializeHandlers(popup);
|
|
581
640
|
this.teach('firstConversation');
|
|
582
641
|
if (Agent.isMine(this.agent)) this.teach('firstPublish');
|
|
583
|
-
}
|
|
642
|
+
};
|
|
643
|
+
onFirstNewPopup();
|
|
584
644
|
}
|
|
585
645
|
teach(classname) {
|
|
586
646
|
if (localStorage.getItem(classname)) return;
|
|
587
647
|
localStorage.setItem(classname, '1');
|
|
588
648
|
document.body.classList.toggle(classname, true);
|
|
649
|
+
document.querySelectorAll('.teach').forEach(element => element.onclick = () => this.closeTeach());
|
|
589
650
|
}
|
|
651
|
+
closeTeach() { // All of them.
|
|
652
|
+
document.body.classList.toggle('firstConversation', false);
|
|
653
|
+
document.body.classList.toggle('firstPublish', false);
|
|
654
|
+
}
|
|
655
|
+
|
|
590
656
|
clearAvatars(popup = this.marker?.getPopup()) {
|
|
591
657
|
popup?.getElement()?.querySelectorAll('.correspondent[data-tag]')
|
|
592
658
|
.forEach(element => Agent.ensure({tag: element.dataset.tag}).removeElement(element, 'mixed', element.classList.contains('avatar') ? 'avatar' : 'handle'));
|
|
@@ -614,10 +680,10 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
614
680
|
filenameDisplay.textContent = fileChooser.files.length ? (fileChooser.files[0].name || 'camera') : '';
|
|
615
681
|
};
|
|
616
682
|
this.initChangeHashtag(popupElement);
|
|
683
|
+
const region = this.region;
|
|
617
684
|
for (const correspondent of popupElement.querySelectorAll('.correspondent')) {
|
|
618
685
|
const tag = correspondent.dataset.tag;
|
|
619
|
-
const
|
|
620
|
-
const agent = Agent.ensure({tag, region: region});
|
|
686
|
+
const agent = Agent.ensure({tag, region});
|
|
621
687
|
const isAvatar = correspondent.classList.contains('avatar');
|
|
622
688
|
if (agent.addElement(correspondent, 'mixed', isAvatar ? 'avatar' : 'handle')) {
|
|
623
689
|
const isMine = Agent.isMine(tag);
|
|
@@ -674,7 +740,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
674
740
|
`;
|
|
675
741
|
return `<md-outlined-button class="changeHashtag">${pubtag}</md-outlined-button>`;
|
|
676
742
|
}
|
|
677
|
-
formatAttributionActions({agent, hashtag}) { //
|
|
743
|
+
formatAttributionActions({agent, hashtag}) { // Answer div HTML containing: [deleter] sharer [hashtag]
|
|
678
744
|
// Where deletere appears if it our reply (no hashtag), and hashtag if present is a button if ours (and otherwise just text).
|
|
679
745
|
const isOurs = agent === Agent.tag;
|
|
680
746
|
const deleter = !hashtag && isOurs ? `<md-outlined-icon-button><md-icon class="material-icons">delete_forever</md-icon></md-outlined-icon-button>` : '';
|
|
@@ -742,9 +808,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
742
808
|
event.stopPropagation();
|
|
743
809
|
const button = event.target;
|
|
744
810
|
const inputElement = button.parentElement;
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
811
|
+
const {tag, hashtag, lat, lng, region} = this;
|
|
812
|
+
let payload = {message: inputElement.value.trim(), lat, lng};
|
|
748
813
|
const files = inputElement.parentElement.querySelector('input[type="file"]').files;
|
|
749
814
|
if (!payload && !files.length) return;
|
|
750
815
|
inputElement.value = '';
|
|
@@ -752,15 +817,17 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
752
817
|
const contact = await networkPromise;
|
|
753
818
|
if (files.length) {
|
|
754
819
|
const {topic:file, msgIds} = await contact.chunkifyBlob({blob: files[0], region});
|
|
755
|
-
payload =
|
|
820
|
+
payload.file = file;
|
|
756
821
|
}
|
|
757
|
-
|
|
822
|
+
// Users won't subscribe to tag unless the alert itself has already landed, and that has lat/lng/hashtag.
|
|
823
|
+
// However, these must be included anyway in case the receiving user clicks on an out-of-band
|
|
824
|
+
// notification at a later time, without the alert being on the map at that moment.
|
|
825
|
+
await contact.publish({eventName: tag, region, payload, hashtag, alert: tag}); // Publish the new reply.
|
|
758
826
|
Agent.current.persistPublicMetadata();
|
|
759
827
|
}
|
|
760
828
|
deleteReply(replyElement) {
|
|
761
829
|
resetInactivityTimer();
|
|
762
|
-
const {lat, lng, tag} = this;
|
|
763
|
-
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
830
|
+
const {lat, lng, region, tag} = this;
|
|
764
831
|
const killTag = replyElement.dataset.tag;
|
|
765
832
|
networkPromise.then(async contact => {
|
|
766
833
|
// We won't be here unless we are the signer.
|
|
@@ -776,30 +843,19 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
776
843
|
}
|
|
777
844
|
});
|
|
778
845
|
}
|
|
779
|
-
showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
|
|
846
|
+
showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.tag, lat = this.lat, lng = this.lng, hashtag = this.hashtag, force = false}) {
|
|
780
847
|
// Give OS notification that comes back to here, unless act is us.
|
|
781
848
|
// All notifications on the same alert (e.g., the post and each reply) have the same tag, so OS can collapse them.
|
|
849
|
+
//console.log('alert showNotification', {lat, lng, hashtag, alert, agent, body, force});
|
|
782
850
|
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
|
-
});
|
|
851
|
+
postServiceMessage('notify', {lat, lng, issuedTime, hashtag, alert, body, force});
|
|
796
852
|
}
|
|
797
853
|
// Each reply element is a DIV.reply with data-tag and data-text attributes that are used in sharing.
|
|
798
854
|
// It contains an attribution header with controls, zero or one attachments, and then the message text.
|
|
799
855
|
// 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
856
|
formatReplies() { // Answer HTML for the replies and input box.
|
|
801
857
|
const { items, agent, originalPosting } = this;
|
|
802
|
-
const formatReply = ({tag, payload, ...rest}) => {
|
|
858
|
+
const formatReply = ({tag, payload, hashtag, ...rest}) => {
|
|
803
859
|
const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
|
|
804
860
|
let text = message
|
|
805
861
|
.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
|
|
@@ -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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { pica, FileReader, File, URL } = globalThis;
|
|
2
|
-
import { resetInactivityTimer } from './main.js';
|
|
2
|
+
import { closeAll, resetInactivityTimer } from './main.js';
|
|
3
3
|
import { showMessage } from './map.js';
|
|
4
4
|
|
|
5
5
|
export function consume(event) { // i.e., don't close dialogs
|
|
@@ -11,6 +11,7 @@ export function openDisplay(containerIdentifier, event = null, content = undefin
|
|
|
11
11
|
// Open containerIdentifier with close handler attached and optional content, and return content element
|
|
12
12
|
const containerElement = document.getElementById(containerIdentifier);
|
|
13
13
|
consume(event);
|
|
14
|
+
closeAll();
|
|
14
15
|
|
|
15
16
|
containerElement.onclick = event => {
|
|
16
17
|
resetInactivityTimer();
|
|
@@ -125,7 +125,8 @@ export const Hashtags = {
|
|
|
125
125
|
if (resetSubscriptions) {
|
|
126
126
|
// We destroy unsubscribed markers right away, because we don't want the user to have to wait and wonder why they're still displayed.
|
|
127
127
|
// If there are alerts in flight, they will be rejected by Alert initialize because we will have already turned off the sub.
|
|
128
|
-
|
|
128
|
+
// The remaining have needsRedisplay set so that their menus are correct.
|
|
129
|
+
Alert.items.forEach(wrapper => (this.isSubscribed(wrapper.hashtag) && (wrapper.needsRedisplay = true)) || wrapper.destroy());
|
|
129
130
|
Alert.updateSubscriptions();
|
|
130
131
|
}
|
|
131
132
|
},
|