@yz-social/civildefense.io 4.4.1

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.
Files changed (77) hide show
  1. package/README.md +68 -0
  2. package/announce.js +24 -0
  3. package/docs/YZ-Brief.pdf +0 -0
  4. package/index.js +2 -0
  5. package/movie/camera.jpg +0 -0
  6. package/movie/movie.js +151 -0
  7. package/movie/script.js +272 -0
  8. package/movie/test.js +2 -0
  9. package/nginx/nginx.conf +83 -0
  10. package/nginx/yz.social +81 -0
  11. package/package.json +34 -0
  12. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -0
  13. package/public/about/CivilDefense.mp4 +0 -0
  14. package/public/about/In case of Nazis, use CivilDefense.io.png +0 -0
  15. package/public/about/broadcast.png +0 -0
  16. package/public/about/civil-defense.png +0 -0
  17. package/public/about/conversation-and-image-high.png +0 -0
  18. package/public/about/conversation-and-image.png +0 -0
  19. package/public/about/en.html +157 -0
  20. package/public/about/es.html +142 -0
  21. package/public/about/flood-fire-ice-high.png +0 -0
  22. package/public/about/flood-fire-ice.png +0 -0
  23. package/public/about/hero-high.png +0 -0
  24. package/public/about/hero.png +0 -0
  25. package/public/about/index.html +8 -0
  26. package/public/about/nazis.png +0 -0
  27. package/public/about/script.js +69 -0
  28. package/public/about/streaming-radio-high.png +0 -0
  29. package/public/about/streaming-radio.png +0 -0
  30. package/public/about/style.css +306 -0
  31. package/public/control-safe-rectangle.html +40 -0
  32. package/public/favicon.ico +0 -0
  33. package/public/images/Achtung.png +0 -0
  34. package/public/images/YZ Owl.png +0 -0
  35. package/public/images/civil-defense-122.png +0 -0
  36. package/public/images/civil-defense-192.png +0 -0
  37. package/public/images/civil-defense-240.png +0 -0
  38. package/public/images/civil-defense-512.png +0 -0
  39. package/public/images/civil-defense.png +0 -0
  40. package/public/images/hero-small.png +0 -0
  41. package/public/images/qr-scan.svg +2 -0
  42. package/public/images/qr.png +0 -0
  43. package/public/images/qr.svg +155 -0
  44. package/public/images/recenter.svg +5 -0
  45. package/public/images/share.png +0 -0
  46. package/public/images/share.svg +2 -0
  47. package/public/index.html +170 -0
  48. package/public/javascripts/agent.js +324 -0
  49. package/public/javascripts/display.js +25 -0
  50. package/public/javascripts/hashtags.js +205 -0
  51. package/public/javascripts/main.js +394 -0
  52. package/public/javascripts/map.js +725 -0
  53. package/public/javascripts/p2pWebNetwork.js +245 -0
  54. package/public/javascripts/s2.js +77 -0
  55. package/public/javascripts/scripting.js +112 -0
  56. package/public/javascripts/service-manager.js +149 -0
  57. package/public/javascripts/translations.js +139 -0
  58. package/public/javascripts/versions.js +23 -0
  59. package/public/manifest.json +25 -0
  60. package/public/owl.ico +0 -0
  61. package/public/platformer.html +52 -0
  62. package/public/robots.txt +6 -0
  63. package/public/service-worker.js +218 -0
  64. package/public/stylesheets/style.css +442 -0
  65. package/routes/index.js +123 -0
  66. package/server/app.js +116 -0
  67. package/server/bridge.js +397 -0
  68. package/server/dirname.js +15 -0
  69. package/server/getLocation.js +18 -0
  70. package/server/identity.js +111 -0
  71. package/server/location.json +12 -0
  72. package/spec/axonSpec.gratuitousNameChangeForSignal +246 -0
  73. package/spec/axonSpec.js +280 -0
  74. package/spec/axonSpec.jsRemoveThePartAfterJS +252 -0
  75. package/spec/civildefenseSpec.js +61 -0
  76. package/spec/pubsubSpec.js +128 -0
  77. package/spec/support/jasmine.mjs +14 -0
@@ -0,0 +1,245 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import { AxonaPeer, AxonaDomain, NeuronNode, createNodeIdentity, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from '@axona/protocol';
3
+ import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from '@axona/protocol/std';
4
+ import { webTransport } from '@axona/protocol/transport/web/index.js';
5
+ globalThis.RTCPeerConnection ||= await import('node-datachannel/polyfill').then(ndc => ndc.RTCPeerConnection);
6
+ const { BigInt, URL, File, pica } = globalThis;
7
+
8
+ /* Example:
9
+ const network = await P2PWebNetwork.create({lat: 37.468467587148844, lng: -122.25860595703126});
10
+ ....
11
+ await network.disconnect();
12
+ */
13
+
14
+ const {promise:sessionRegionPromise, resolve:resolveSessionRegion} = Promise.withResolvers();
15
+
16
+ export class P2PWebNetwork {
17
+ static wireVersion = WIRE_VERSION;
18
+ static kernelVersion = KERNEL_VERSION;
19
+ static createAuthorIdentity = createAuthorIdentity;
20
+ static setSessionRegion = resolveSessionRegion;
21
+ static sessionRegion = sessionRegionPromise;
22
+ static async create({infoLogger = console.log, debugLogger,
23
+ region, identity, bridgeUrl = 'wss://bridge.axona.net',
24
+ synapseCount = 4, timeoutMs = 10e3} = {}) {
25
+ // Promise a ready-to-use network peer.
26
+ // Complex region/identity behavior: Must pass either identity or region (either can be a promise), or will wait for setSessionRegion() to be called.
27
+ if (!identity) region ||= this.canonicalizeRegion(await (region || this.sessionRegion));
28
+ identity ||= createNodeIdentity(region);
29
+ identity = await identity;
30
+ region ||= identity.region;
31
+
32
+ const transport = webTransport({bridgeUrl, identity});
33
+ const node = new NeuronNode({lat: region.lat, lng: region.lng, id: BigInt('0x' + identity.id)});
34
+ node.transport = transport; // FIXME: pass in to constructor?
35
+ const domain = new AxonaDomain({ k: 20 }); // FIXME: can't this be defaulted in AxonaPeer?
36
+ const peer = new AxonaPeer({domain, node, identity, transport});
37
+
38
+ const network = new this();
39
+ Object.assign(network, {infoLogger, debugLogger, identity, transport, node, peer});
40
+ network.resetStatePromises();
41
+ network.info('Created network node for kernel', this.kernelVersion);
42
+ await network.connect({synapseCount, timeoutMs});
43
+ return network;
44
+ }
45
+
46
+ async connect({synapseCount = 4, timeoutMs = 10e3} = {}) {
47
+ // Returned promise resolves when ready for use. Can be cycled through disconnect()/connect().
48
+ await this.transport.start(this.identity.id);
49
+ await this.join();
50
+ this.debug('Joined', this.health().synaptomeSize, 'connections.');
51
+ if (parseInt(this.constructor.kernelVersion) < 4) {
52
+ const t0 = Date.now();
53
+ while (Date.now() - t0 < timeoutMs) {
54
+ const size = this.synaptomeSize;
55
+ if (size >= synapseCount) break;
56
+ await this.constructor.delay(200);
57
+ }
58
+ } else {
59
+ await this.peer.ready({ minPeers: synapseCount, timeoutMs });
60
+ }
61
+ this.info('Connected', this.health().synaptomeSize, 'connections.');
62
+ this.attached(this);
63
+ return this;
64
+ }
65
+ async disconnect() { // Politely close network connection.
66
+ const health = this.health();
67
+ await this.leave();
68
+ this.info(`disconnected with ${health.peers.length} connections and ${health.axonRoles.length} axons.`);
69
+ await this.stop();
70
+ this.resetStatePromises();
71
+ }
72
+ async replicateStorage() { // Let the network know that we might go away without further notice.
73
+ // FIXME. It would be great if we could remove ourselves from any non-leaf positions in the Axon, but stay subscribed.
74
+ }
75
+ fastDisconnect() { // Synchronous attempt to be polite to those connected.
76
+ this.leave(); // Execution is asynchronous. Will not finish -- or perhaps even really start -- within the call.
77
+ }
78
+
79
+ async chunkifyString({string, region, signWith = this.constructor.currentPublishIdentity, owner = signWith.authorId}) {
80
+ // Publish string and answer an identifier that can be used to re-assemble.
81
+ if (!string.length) throw new Error(`Cannot chunkify empty string '${string}.`);
82
+ const topic = {name: uuidv4(), region, owner};
83
+ const data = await publishChunkedBytes(this.peer, stringToBytes(string), {topic, signWith});
84
+ return data.topic;
85
+ }
86
+ async assembleChunkedString(topic) { // Promise the string that was chunkified to topic.
87
+ const data = await receiveChunkedBytes(this.peer, topic, {/*, onProgress: console.log*/});
88
+ return bytesToString(data.bytes);
89
+ }
90
+
91
+ static getCanvas(file) { // Promise a Canvas from a File of type image/*.
92
+ return new Promise((resolve, reject) => {
93
+ const img = new Image();
94
+ const canvas = document.createElement('canvas');
95
+ const ctx = canvas.getContext('2d');
96
+ img.onload = () => {
97
+ canvas.width = img.width;
98
+ canvas.height = img.height;
99
+ ctx.drawImage(img, 0, 0);
100
+ URL.revokeObjectURL(img.src);
101
+ resolve(canvas);
102
+ };
103
+ img.onerror = reject;
104
+ img.src = URL.createObjectURL(file);
105
+ });
106
+ }
107
+ static async downsampledBlob({blob, outputType = 'image/jpeg', maxDimension = 1024}) {
108
+ // Promise a reasonably sized Blob (or File) for a given Blob of type image/*, else blob unchanged.
109
+ if (!blob.type.startsWith('image/')) return blob;
110
+
111
+ let sizedWidth, sizedHeight; // Largest will be 1024, preserving aspect ratio.
112
+ const from = await this.getCanvas(blob);
113
+ const {width, height} = from;
114
+ if (width > height) {
115
+ sizedWidth = maxDimension;
116
+ sizedHeight = Math.round(maxDimension * height/width);
117
+ } else {
118
+ sizedHeight = maxDimension;
119
+ sizedWidth = Math.round(maxDimension * width/height);
120
+ }
121
+ if ((blob.type === outputType) && (sizedWidth >= width)) return blob;
122
+
123
+ const resizer = pica();
124
+ const to = document.createElement('canvas');
125
+ to.width = sizedWidth;
126
+ to.height = sizedHeight;
127
+ const buffer = await resizer.resize(from, to);
128
+ outputType ||= blob.type;
129
+ let result = await resizer.toBlob(buffer, outputType, 0.90);
130
+ if (blob.name) { // Answer a File with original name, but with extension matching result type.
131
+ let {name} = blob;
132
+ const parts = name.split('.');
133
+ const type = result.type;
134
+ parts[parts.length - 1] = type.slice('image/'.length);
135
+ name = parts.join('.');
136
+ result = new File([result], name, {type});
137
+ }
138
+ return result;
139
+ }
140
+ static u82dataURL(u8, mime) { // Answer a dataURL from the Uint8Array and mime type string.
141
+ return `data:${mime};base64,${u8.toBase64()}`;
142
+ }
143
+ static async blob2dataURL(blob) { // Promise a dataURL preserving mime type (but not File name, if any).
144
+ const buffer = await blob.arrayBuffer();
145
+ const u8 = new Uint8Array(buffer);
146
+ return this.u82dataURL(u8, blob.type);
147
+ }
148
+ static async dataURL2blob(dataURL, filename='') { // Promise a Blob.
149
+ const res = await fetch(dataURL);
150
+ const blob = await res.blob();
151
+ if (!filename) return blob;
152
+ return new File([blob], filename, {type: blob.type});
153
+ }
154
+ async chunkifyBlob({blob, region, signWith = this.constructor.currentPublishIdentity, owner = signWith.authorId, maxDimension = 1024, ...rest}) {
155
+ // Publish Blob (or File) and answer an identifier that can be used to re-assemble.
156
+ if (!blob.size) throw new Error(`Cannot chunkify empty Blob.`);
157
+ if (maxDimension) blob = await this.constructor.downsampledBlob({blob, maxDimension});
158
+ const {type:mime, name} = blob;
159
+ const topic = {name: uuidv4(), region, owner};
160
+ const buffer = await blob.arrayBuffer();
161
+ const u8 = new Uint8Array(buffer);
162
+ console.log('blob', blob.size, u8.length);
163
+ const data = await publishChunkedBytes(this.peer, u8, {topic, signWith, mime, name, ...rest});
164
+ return data.topic;
165
+ }
166
+ async assembleChunkedDataURL(topic) { // Promise {bytes, mime, name, dataURL} that was chunkified to topic.
167
+ const data = await receiveChunkedBytes(this.peer, topic, {/*, onProgress: console.log*/});
168
+ // Using dataURL is not terribly efficient, but it is convenient, because formatReplies can return HTML strings with all the data in them,
169
+ // instead of, e.g., needing javascript to later set properties of elements to createObjectURL of a Blob.
170
+ data.dataURL = this.constructor.u82dataURL(data.bytes, data.mime);
171
+ return data;
172
+ }
173
+
174
+ // The methods publish/subscribe map from the original civildefense-over-kdht API to Axona, and could be rewritten in the apps.
175
+ // But since we needed this class anyway, it was easiest to retain them.
176
+ // Besides, I don't like to see abbreviations in API names.
177
+ async subscribe({eventName, region, owner, since = 'all', handler}) { // Assign handler for eventName, or remove any handler if falsy.
178
+ await this.attachment;
179
+ const topic = {region, name: eventName};
180
+ if (owner) topic.owner = owner;
181
+ if (handler) {
182
+ const callback = async envelope => {
183
+ const {message, deleted, msgId, signerPubkey, topic, ts} = envelope;
184
+ //console.log('fired', {msgId, topic, ts, signerPubkey, deleted, message});
185
+ if (deleted) {
186
+ handler({subject: msgId, payload: null, agent: signerPubkey, topic, ts}); // fixme remove topic, ts here and below.
187
+ return;
188
+ }
189
+ handler({...message, agent: signerPubkey, subject: msgId, topic, ts});
190
+ };
191
+ await this.peer.sub(topic, callback, {since});
192
+ } else {
193
+ this.peer.unsub(topic, {});
194
+ }
195
+ }
196
+ static currentPublishIdentity = null;
197
+ async publish({eventName, region, owner, signWith = this.constructor.currentPublishIdentity, issuedTime = Date.now(), subject, payload, ...rest}) {
198
+ // Publish data to subscribers of eventName.
199
+ await this.attachment; // Get connected.
200
+ const topic = {region, name: eventName};
201
+ if (owner) topic.owner = owner;
202
+ const options = {signWith};
203
+ //console.log({topic, subject, payload, issuedTime, rest, signWith});
204
+ if (payload) return await this.peer.pub(topic, {issuedTime, payload, ...rest}, options);
205
+ // The next would not normally happen, but until since:'latest' works, we need a way to send a null payload and have the handler delete the entry.
206
+ if (!subject) return await this.peer.pub(topic, {issuedTime, payload, ...rest}, options);
207
+ return await this.peer.kill(topic, subject, options);
208
+ }
209
+
210
+ // Mostly internal stuff.
211
+ static regionCode(lat, lng) { // Answer containing region code.
212
+ return geoCellId(lat, lng);
213
+ }
214
+ static delay(ms, result) { // Promise result after ms milliseconds.
215
+ return new Promise(resolve => setTimeout(resolve, ms, result));
216
+ }
217
+ resetStatePromises() { // Fire any existing detach(), and assign new promises and resolvers for attachment and detachment.
218
+ const existingDetachedResolver = this.detached;
219
+ const {promise:attachment, resolve:attached} = Promise.withResolvers();
220
+ const {promise:detachment, resolve:detached} = Promise.withResolvers();
221
+ Object.assign(this, {attachment, detachment, attached, detached});
222
+ existingDetachedResolver?.();
223
+ }
224
+ static canonicalizeRegion(lat, lng) {
225
+ // Answer a {lat, lng} that is the center of a top-level Axona region containing the given {lat, lng}.
226
+ // E.g., a precise location gets anonymized to containing top-level cell center.
227
+ return geoCellCenter(geoCellId(lat, lng));
228
+ }
229
+ get synaptomeSize() { // Safely answer the number of connections.
230
+ return this.node.synaptome?.size ?? 0;
231
+ }
232
+ // TODO: Integrate with AxonaPeer's complex logging.
233
+ debug(...rest) { // Add debug logspam.
234
+ this.debugLogger?.(this.identity.id, ...rest);
235
+ }
236
+ info(...rest) { // Add debug logspam.
237
+ (this.infoLogger || this.debugLogger)?.(this.identity.id, ...rest);
238
+ }
239
+ }
240
+ export default P2PWebNetwork;
241
+
242
+ // For now, we want to override publish and subscribe, but that conflicts with internal messages on AxonaPeer.
243
+ // Thus P2PWebNetwork has an AxonaPeer, instead of inheriting from it. And thus we need forwarding messages.
244
+ ['join', 'leave', 'stop', 'health', 'host', 'unhost']
245
+ .forEach(methodName => P2PWebNetwork.prototype[methodName] = function (...rest) {return this.peer[methodName](...rest);});
@@ -0,0 +1,77 @@
1
+ import { s2 } from 's2js';
2
+ const { cellid, LatLng, Point, Cell, Cap, RegionCoverer } = s2;
3
+
4
+ // s2 defines non-overlapping cells that completely cover the globe, at several different levels of cell-size.
5
+ // Each cell, at each level, has its own unique id, which we use a pub/sub key.
6
+ // Here we work with only the 0 to MAX_LEVEL largest levels, where MAX_LEVEL is the smallest size.
7
+ //
8
+ // When publishing, we publish to each s2 key that identifies a cell within our levels that contains the user-selected point.
9
+
10
+ // Meanwhile as the user changes the area being shown, we subscribe to whatever cells we need in order to cover the display
11
+ // area without overlapping cells.
12
+
13
+ export const MIN_LEVEL = 2; // Corresponds to the top level Axona regions.
14
+ const MAX_S2_LEVEL = 30; // The leaf level that Cell.fromPoint operates at.
15
+ const MAX_MAP_LEVEL = 17; // The max level that findCoverCellsByCenterAndRadius will use on our maps.
16
+
17
+ const EARTH_RADIUS_METERS = 6371e3;
18
+
19
+ export function getPointInCell(cellId) { // answer [lat, lng] in degrees.
20
+ let {lat, lng} = s2.cellid.latLng(cellId);
21
+ const degrees = 180 / Math.PI;
22
+ lat *= degrees;
23
+ lng *= degrees;
24
+ return [lat, lng];
25
+ }
26
+
27
+ // Return a list of the cell ids that contain the point.
28
+ export function getContainingCells(lat, lng) {
29
+ const userLatLng = LatLng.fromDegrees(lat, lng);
30
+ const userPt = Point.fromLatLng(userLatLng);
31
+ // Get leaf-level CellId (level 30)
32
+ const userLocCellId = Cell.fromPoint(userPt).id; // This is at level 30.
33
+ let cells = Array(MAX_S2_LEVEL);
34
+ for (let level = 0; level <= MAX_S2_LEVEL; level++) { // This would be more efficient going backwards using immediateParent, but who cares.
35
+ cells[level] = cellid.parent(userLocCellId, level);
36
+ }
37
+ return cells.slice(MIN_LEVEL, MAX_MAP_LEVEL + 1); // We can only make use between Axona region size and the smallest region our maps subscribe to.
38
+ }
39
+
40
+ // Return a list of cell ids that covers a circle specified by a center and a point on that circle, without overlapped cells.
41
+ export function findCoverCellsByCenterAndPoint(centerLat, centerLng, pointLat, pointLng) {
42
+ // TODO: Does it make sense to do this by the actual borders shown, rather than by the length of the half-diagonal?
43
+ const center = Point.fromLatLng(LatLng.fromDegrees(centerLat, centerLng));
44
+ const point = Point.fromLatLng(LatLng.fromDegrees(pointLat, pointLng));
45
+ const distanceAngle = center.distance(point);
46
+ const interestRadiusMeters = distanceAngle * EARTH_RADIUS_METERS;
47
+ return findCoverCellsByCenterAndRadius(centerLat, centerLng, interestRadiusMeters);
48
+ }
49
+
50
+ // Return a list of cell ids that covers interestRadiusMeters around latitude/longitude, without overlapped cells.
51
+ export function findCoverCellsByCenterAndRadius(lat, lng, interestRadiusMeters) {
52
+ const point = Point.fromLatLng(LatLng.fromDegrees(lat, lng));
53
+
54
+ // replicating key parts of Cap.cellUnionBound():
55
+ // Find the maximum (i.e., finest-grained) level such that the cap contains at
56
+ // most [ael: at least??] one cell vertex and such that CellID.AppendVertexNeighbors() can be called.
57
+ const findLevel = radius => {
58
+ let levelForRadius = MAX_S2_LEVEL;
59
+ const radiusAngle = radius / EARTH_RADIUS_METERS;
60
+ if (radiusAngle > 0) {
61
+ const deriv = 2 * Math.SQRT2 / 3;
62
+ levelForRadius = Math.floor(Math.log2(deriv / radiusAngle));
63
+ if (levelForRadius > MAX_S2_LEVEL) levelForRadius = MAX_S2_LEVEL;
64
+ if (levelForRadius < 0) levelForRadius = 0;
65
+ }
66
+ return levelForRadius;
67
+ };
68
+ const levelForRadius = findLevel(interestRadiusMeters); // - 1; // as seen in cellUnionBound: go one level bigger
69
+
70
+ const minLevel = Math.max(MIN_LEVEL, levelForRadius - 1);
71
+ const maxLevel = Math.max(MIN_LEVEL, levelForRadius + 2);
72
+ const rc = new RegionCoverer({ minLevel, maxLevel, maxCells: 9 }); // Will exceed maxCells as needed to obey minLevel.
73
+ const r = Cap.fromCenterAngle(point, interestRadiusMeters / EARTH_RADIUS_METERS);
74
+ return rc.covering(r);
75
+ }
76
+
77
+
@@ -0,0 +1,112 @@
1
+ // A module with utilities for scripting.
2
+
3
+ const { SpeechSynthesisUtterance, speechSynthesis} = window;
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import { Node } from '@yz-social/kdht';
6
+ import { getContainingCells } from './s2.js';
7
+ import { delay, positionWatch, networkPromise, disableNotifications } from './main.js';
8
+ import { Marker, map, updateLocation, updateSubscriptions, makeEventName, showMessage } from './map.js';
9
+ import { Hashtags } from './hashtags.js';
10
+ import { Agent } from './agent.js';
11
+
12
+ function toggle(canonicalTag, kill = false) { // Toggle subscription of specified hashtag (without emoji). Return a promise that resolves after a beat.
13
+ const label = Hashtags.canonical2extended[canonicalTag];
14
+ const chip = Hashtags.getChip(label);
15
+ if (kill) return Hashtags.remove(chip, true);
16
+ chip.selected = !chip.selected;
17
+ Hashtags.toggleChip(chip);
18
+ Hashtags.onchange();
19
+ return delay();
20
+ }
21
+ async function flyTo(center, zoom) { // Fly map to specified position/zoom, and return a promise that resolves when complete, with time to resubscribe.
22
+ const {promise:promiseMove, resolve:resolveMove} = Promise.withResolvers();
23
+ const {promise:promiseZoom, resolve:resolveZoom} = Promise.withResolvers();
24
+ map.on('moveend', resolveMove);
25
+ map.on('zoomend', resolveZoom);
26
+ map.flyTo(center, zoom);
27
+ await Promise.all([promiseMove, promiseZoom]);
28
+ map.off('moveend', resolveMove);
29
+ map.off('zoomend', resolveZoom);
30
+ await delay();
31
+ }
32
+ async function pubSome({payload, act, hashtag, subject, issuedTime, lat = payload.lat, lng = payload.lng}) { // promise to publish to cells containing lat/lng.
33
+ const contact = await networkPromise;
34
+ const cells = getContainingCells(lat, lng);
35
+ for (const cell of cells) {
36
+ const eventName = makeEventName(cell, hashtag);
37
+ const key = await Node.key(eventName);
38
+ await contact.publish({eventName, key, subject, payload, issuedTime, hashtag, act, immediate: true});
39
+ }
40
+ }
41
+ async function killSome(tag) { // Kill all existing pub in-scope by tag.
42
+ tag = Hashtags.canonical2extended[tag] || tag;
43
+ const markers = Object.values(Marker.markers);
44
+ const contact = await networkPromise;
45
+ for (const wrapper of markers) {
46
+ if (wrapper.hashtag !== tag) continue;
47
+ const {act, hashtag, subject, replies, lat, lng} = wrapper;
48
+ for (const {act:replier, subject:reply} of replies)
49
+ await contact.publish({eventName: subject, payload: null, act:replier, hashtag, subject: reply});
50
+ await pubSome({payload: null, lat, lng, act, hashtag, subject});
51
+ };
52
+ }
53
+
54
+ async function drop({lat, lng,
55
+ tag = Hashtags.getPublish(),
56
+ user = Agent.tag,
57
+ open = false,
58
+ issuedTime = Date.now()
59
+ }) { // Drop a pin labeled by tag (which can be canonical) at position, and return a promise that resolves to subject after a beat.
60
+ const act = user;
61
+ const hashtag = Hashtags.canonical2extended[tag] || tag;
62
+ const subject = uuidv4();
63
+ const payload = {lat, lng};
64
+ await pubSome({payload, act, hashtag, subject, issuedTime});
65
+ if (open) Marker.openPopup(subject);
66
+ return delay(800, subject);
67
+ }
68
+ async function gotFile() { // Return a promise that resolves one tick after the file chooser is changed on the current popup (which must be already open).
69
+ // Opening the file chooser must be done manually by the user.
70
+ // The intended usage for scripting is that the recording will capture the user clicking the attachment button to open the chooser.
71
+ const popupElement = map._popup.getElement();
72
+ const fileChooser = popupElement.querySelector('input[type="file"]');
73
+ const {promise, resolve} = Promise.withResolvers();
74
+ fileChooser.addEventListener('change', resolve);
75
+ await promise;
76
+ fileChooser.removeEventListener('change', resolve);
77
+ }
78
+ async function type(text, input) { // Select input text box in the currently already open popup, enter text, click send, and promise a beat.
79
+ let popupElement;
80
+ if (!input) {
81
+ popupElement = map._popup.getElement();
82
+ input = popupElement.querySelector('.reply-input');
83
+ }
84
+ input.focus();
85
+ await delay(3200);
86
+ const interval = 100;
87
+ for (let index = 0; index < text.length; index++) {
88
+ input.value += text[index];
89
+ input.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); // Trigger input handler to resize and enable reply button.
90
+ await delay(interval);
91
+ }
92
+ if (popupElement) popupElement.querySelector('md-filled-icon-button').click();
93
+ else input.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
94
+ await delay();
95
+ }
96
+ function addShim() { // Drop a shim over the scene, and return it.
97
+ const shim = document.createElement('div');
98
+ shim.style = "position: absolute; top: 0; width: 100vw; height: 100vh; z-index: 1200; background: black;";
99
+ document.body.append(shim);
100
+ return shim;
101
+ }
102
+
103
+ function announce(text) { // Speak text and return a promise that resolves when done.
104
+ console.log(text);
105
+ let utterance = new SpeechSynthesisUtterance(text);
106
+ const {promise, resolve} = Promise.withResolvers();
107
+ utterance.onend = resolve;
108
+ speechSynthesis.speak(utterance);
109
+ return promise;
110
+ }
111
+
112
+ export { uuidv4, Node, getContainingCells, delay, positionWatch, networkPromise, disableNotifications, Marker, map, updateLocation, updateSubscriptions, makeEventName, showMessage, Hashtags, Agent, toggle, flyTo, pubSome, killSome, drop, gotFile, type, addShim, announce };
@@ -0,0 +1,149 @@
1
+ const { Request, Response, URL, localStorage, BroadcastChannel } = globalThis;
2
+ import { appVersion } from './versions.js';
3
+ import { resetInactivityTimer, clickTip } from './main.js';
4
+ import { openDisplay } from './display.js';
5
+ import { go } from './map.js';
6
+ import { Int } from './translations.js';
7
+
8
+ /*
9
+ Registers and interacts with the service worker, to provide:
10
+
11
+ cached sources with an upgrade path
12
+ -----------------------------------
13
+ key behaviors:
14
+ 1. New code won't be used (even on refresh) until the user agrees.
15
+ 2. Even a refreshed page does not require the web server. (Access to the DHT must still be provded. That's not handled here.)
16
+ 3. Any and all tabs are updated to new version.
17
+
18
+ We use the browser's service worker update mechanism to allow the user to control caching and reload with the right version.
19
+
20
+ The service WORKER does NOT fill any cache on installation, nor delete old on activation (as many service worker examples do).
21
+ Instead, if the service MANAGER sees that the appVersion cache does not exist yet, it explicitly fills it with the host's current source.
22
+
23
+ The service WORKER handles requests from ANY cache version, rather than just serviceVersion, but STORES missing items in serviceVersion.
24
+ Thus the explicitly filled source is from the one-time filling by the service MANAGER.
25
+ Other requests on the web (such as map tiles) are cached to serviceVersion, responded from there, and do not get updated until cache is cleared or new app version installed.
26
+
27
+ New service workers on host are picked up and installed, either by the user pressing a button that asks the browser to try to
28
+ update the service worker registration (by looking for a new version on host), or by the browser doing this automatically every 24 hours.
29
+ Either way, it still responds with any resources that have already been cached, including all old source and external resources that have already been cached.
30
+ New external resources that have not yet been cached (e.g. map tiles for areas not yet visited) are fetched and cached under new serviceVersion going forward.
31
+ The user is informed (in About and in a popup), and we persist a marker in storage in case the user reloads (which would not otherwise see a new serviceVersion
32
+ because it already has it).
33
+
34
+ When user says to update, the service MANAGER deletes the appVersion cache (so that next step doesn't use old data), explicitly fills the new serviceVersion cache,
35
+ and then we reload with new v=version parameter.
36
+ This busts the browser's caching so that it reloads index.html from the new cache.
37
+ (The v parameter is stripped on load so that it doesn't hang around. Other parameters are not affected.)
38
+ The service WORKER responds with the new cached source, and external resources have been pulled no earlier than the host's service worker release.
39
+ We also broadcast to any other open tabs at the same host, so that they reload to the new v as well.
40
+
41
+ When caching an explicit list of files for use going forward, that list is defined in the new/current service worker, not the stale/cached service manager.
42
+
43
+ If the user clears cache and reloads (even if there is no source/worker update, or a stale worker), they get the currently hosted versions.
44
+ */
45
+
46
+ let resolveCached;
47
+ async function cacheSource(version) {
48
+ console.log(`service-manager ${appVersion} requesting service-worker to cache source in ${version}.`);
49
+ const {promise, resolve} = Promise.withResolvers();
50
+ resolveCached = resolve;
51
+ const registration = await navigator.serviceWorker.ready;
52
+ registration.active.postMessage({method: 'cacheSource', params: version});
53
+ await promise;
54
+ console.log(`source ${version} is cached.`);
55
+ }
56
+
57
+ const checkButton = document.getElementById('checkForUpdates');
58
+ const updateText = document.getElementById('updateStatus');
59
+ const downloadButton = document.getElementById('downloadUpdates');
60
+ const downloadButton2 = document.getElementById('downloadUpdates2');
61
+
62
+ function getServiceVersion(registration) { // Ask the service worker to send back it's version, which will trigger a compare.
63
+ console.log('requesting service-worker version');
64
+ registration.active.postMessage({method: 'version', params: appVersion});
65
+ }
66
+ function newVersionAvailable(newVersion) {
67
+ // Set up all the buttons and displays in case the user declines the popup,
68
+ // and then open the popup.
69
+ checkButton.classList.toggle('hidden', true);
70
+ downloadButton.classList.toggle('hidden', false);
71
+ updateText.textContent = `${Int`Version`} ${newVersion} ${Int`available`}.`;
72
+ openDisplay('updateContainer');
73
+ }
74
+ async function installUpdate(newVersion) {
75
+ await caches.delete(appVersion); // Must be before cacheSource, or we'll just recache the same files!
76
+ await cacheSource(newVersion);
77
+ // Reload, but convince all browsers to re-"fetch" (through the new service worker that is now running).
78
+ const url = new URL(location.href);
79
+ url.searchParams.set('v', newVersion); // Preserving any other searchParams.
80
+ // For any other tabs in THIS browser:
81
+ new BroadcastChannel('site_control').postMessage({method: 'reload', params: url.href});
82
+ window.location.assign(url.href);
83
+ }
84
+
85
+ // First time or after clearing cache, cache latest version of app.
86
+ if (!(await caches.has(appVersion))) cacheSource(appVersion);
87
+
88
+ await navigator.serviceWorker
89
+ .register("/service-worker.js", {updateViaCache: 'none', type: 'module'})
90
+ .then(registration => {
91
+ let serviceVersion;
92
+ // No need to reset button/status on click, because we will be reloading.
93
+ const installText = Int`Update to a new version of this app.`;
94
+ clickTip(downloadButton, installText, () => installUpdate(serviceVersion));
95
+ clickTip(downloadButton2, installText, () => event => {
96
+ event.stopPropagation();
97
+ installUpdate(serviceVersion);
98
+ });
99
+ clickTip(checkButton, Int`Check to see if a new version of the app is available.`, async event => {
100
+ resetInactivityTimer();
101
+ event.stopPropagation();
102
+ await registration.update();
103
+ updateText.textContent = `${Int`No update at`} ${new Date().toLocaleString()}.`;
104
+ });
105
+ registration.onupdatefound = () => { // A new service worker has been installed because of a service worker script change.
106
+ const newWorker = registration.installing;
107
+ console.log('updatefound', newWorker.state, navigator.serviceWorker, navigator.serviceWorker.controller);
108
+ newWorker.onstatechange = () => {
109
+ console.log('statechange', newWorker.state, navigator.serviceWorker, navigator.serviceWorker.controller);
110
+ // We don't want to nag/confuse the user when installing fresh/first-time. There will not be a controller that time.
111
+ // if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
112
+ // getServiceVersion(registration);
113
+ // }
114
+ };
115
+ };
116
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
117
+ console.log('controllerchange', navigator.serviceWorker, navigator.serviceWorker.controller);
118
+ if (!navigator.serviceWorker.controller) return;
119
+ getServiceVersion(registration);
120
+ });
121
+ // addEventListener, allowing other code to listen for other messages.
122
+ navigator.serviceWorker.addEventListener('message', async event => {
123
+ const {method, params} = event.data;
124
+ switch (method) {
125
+ case 'version':
126
+ console.log('Comparing service worker version', params, 'to app version', appVersion);
127
+ if (params === appVersion) {
128
+ //console.log('Checked version', appVersion);
129
+ } else {
130
+ serviceVersion = params;
131
+ newVersionAvailable(params);
132
+ }
133
+ break;
134
+ case 'cached':
135
+ resolveCached?.(params);
136
+ break;
137
+ case 'go':
138
+ go(params);
139
+ break;
140
+ default:
141
+ console.error('Unrecognized message from service worker', event.data);
142
+ }
143
+ });
144
+ navigator.serviceWorker.ready.then(getServiceVersion);
145
+ });
146
+ new BroadcastChannel('site_control').onmessage = event => {
147
+ const {method, params} = event.data;
148
+ if (method === 'reload') window.location.assign(params);
149
+ };