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