@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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// If ?dht=0, use a websocket to the server instead of Axona.
|
|
2
|
+
const { TextEncoder, TextDecoder, BigInt, URL, WebSocket, Buffer } = globalThis;
|
|
3
|
+
|
|
4
|
+
let connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION, stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes;
|
|
5
|
+
|
|
6
|
+
// dht 1 -> Axona (default)
|
|
7
|
+
// dht 0 -> server
|
|
8
|
+
// dht -1 -> in-memory on client only
|
|
9
|
+
const dht = parseInt(globalThis.process ? globalThis.process.env.DHT : new URL(globalThis.location).searchParams.get('dht'));
|
|
10
|
+
|
|
11
|
+
if (dht < 1) {
|
|
12
|
+
|
|
13
|
+
const { v4:uuidv4 } = await import('uuid');
|
|
14
|
+
const { getContainingCells, getPointInCell } = await import('./s2.js');
|
|
15
|
+
const { cellHex } = await import('./versions.js');
|
|
16
|
+
const operator = await import('./pubsub.js');
|
|
17
|
+
|
|
18
|
+
WIRE_VERSION = 'SERVER';
|
|
19
|
+
KERNEL_VERSION = `${WIRE_VERSION}.1.0`;
|
|
20
|
+
createAuthorIdentity = ({
|
|
21
|
+
persistAs, store = {
|
|
22
|
+
get: (key) => globalThis.localStorage.getItem(key),
|
|
23
|
+
set: (key, value) => globalThis.localStorage.setItem(key, value)
|
|
24
|
+
}}) => {
|
|
25
|
+
let tag;
|
|
26
|
+
if (persistAs) {
|
|
27
|
+
tag = store.get(persistAs);
|
|
28
|
+
if (!tag) {
|
|
29
|
+
tag = uuidv4();
|
|
30
|
+
store.set(persistAs, tag);
|
|
31
|
+
} else if (tag.includes('pubkey')) {
|
|
32
|
+
tag = JSON.parse(tag).pubkey; // if it is a real dump, as for alert-bot.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {authorId: tag};
|
|
36
|
+
};
|
|
37
|
+
geoCellId = (lat, lng) => {
|
|
38
|
+
const cells = getContainingCells(lat, lng);
|
|
39
|
+
const hex = cellHex(cells[0]);
|
|
40
|
+
const sliced = hex.slice(0, 2);
|
|
41
|
+
return parseInt(sliced, 16); // The worst way to do this.
|
|
42
|
+
};
|
|
43
|
+
geoCellCenter = regionCode => { // Not right. See getPointInCell comments.
|
|
44
|
+
const expanded = regionCode.toString(16).padStart(2, '0').padEnd(16, '0');
|
|
45
|
+
const cellid = BigInt('0x' + expanded);
|
|
46
|
+
const [lat, lng] = getPointInCell(cellid);
|
|
47
|
+
return {lat, lng};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
bytesToString = u8 => {
|
|
51
|
+
return new TextDecoder().decode(u8);
|
|
52
|
+
};
|
|
53
|
+
stringToBytes = (str) => {
|
|
54
|
+
return new TextEncoder().encode(str);
|
|
55
|
+
};
|
|
56
|
+
const hasBuffer = typeof Buffer !== 'undefined';
|
|
57
|
+
function bytesToB64(u8) {
|
|
58
|
+
if (hasBuffer) return Buffer.from(u8).toString('base64');
|
|
59
|
+
let s = ''; const CH = 0x8000;
|
|
60
|
+
for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH));
|
|
61
|
+
return btoa(s);
|
|
62
|
+
}
|
|
63
|
+
function b64ToBytes(b64) {
|
|
64
|
+
if (hasBuffer) return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
65
|
+
const s = atob(b64); const u8 = new Uint8Array(s.length);
|
|
66
|
+
for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i);
|
|
67
|
+
return u8;
|
|
68
|
+
}
|
|
69
|
+
publishChunkedBytes = (peer, u8, {name, mime}) => {
|
|
70
|
+
const str = bytesToB64(u8);
|
|
71
|
+
return {topic: {name, mime, str}};
|
|
72
|
+
};
|
|
73
|
+
receiveChunkedBytes = (peer, {name, mime, str}, options) => {
|
|
74
|
+
const u8 = b64ToBytes(str);
|
|
75
|
+
return {bytes: u8, name, mime, msgIds: []};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
connect = async ({bridge, location, onDisconnect}) => {
|
|
79
|
+
// We always call location as {lat, lng}
|
|
80
|
+
// We always call it with author:false
|
|
81
|
+
const {lat, lng} = location;
|
|
82
|
+
const region = geoCellId(lat, lng);
|
|
83
|
+
const nodeTag = region.toString(16).padStart(2, '0') + uuidv4();
|
|
84
|
+
const nodeIdentity = {id: nodeTag};
|
|
85
|
+
const handlers = {}; // guid => handler tag
|
|
86
|
+
const inFlight = {};
|
|
87
|
+
let disconnect, transport; // But do not call P2PWebNetwork.ice!!!
|
|
88
|
+
const send = await new Promise(resolve => {
|
|
89
|
+
if (dht === 0) {
|
|
90
|
+
const url = `${bridge}/${nodeTag}`;
|
|
91
|
+
const socket = transport = new WebSocket(url);
|
|
92
|
+
socket.onmessage = event => {
|
|
93
|
+
const [tag, ...rest] = JSON.parse(event.data);
|
|
94
|
+
const subHandler = handlers[tag];
|
|
95
|
+
const inFlightResolver = inFlight[tag];
|
|
96
|
+
if ((!subHandler && !inFlightResolver) ||
|
|
97
|
+
((typeof(subHandler) !== 'function') && (typeof(inFlightResolver) !== 'function')))
|
|
98
|
+
console.log({tag, rest, subHandler, inFlightResolver, handlers, inFlight});
|
|
99
|
+
if (subHandler) return subHandler(...rest);
|
|
100
|
+
delete inFlight[tag];
|
|
101
|
+
return inFlightResolver?.(...rest);
|
|
102
|
+
};
|
|
103
|
+
socket.onopen = () => {
|
|
104
|
+
if (socket.readyState !== WebSocket.OPEN) return; // You would think that can't happen, but...
|
|
105
|
+
resolve((...rest) => { // send()
|
|
106
|
+
const tag = uuidv4();
|
|
107
|
+
const {promise, resolve} = Promise.withResolvers();
|
|
108
|
+
inFlight[tag] = resolve;
|
|
109
|
+
socket.send(JSON.stringify([tag, ...rest]));
|
|
110
|
+
return promise;
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
// onerror is of no help, as the event is generic.
|
|
114
|
+
socket.onclose = event => {
|
|
115
|
+
console.warn('websocket close', event.code, event.wasClean, event.reason);
|
|
116
|
+
onDisconnect();
|
|
117
|
+
};
|
|
118
|
+
disconnect = () => socket.close();
|
|
119
|
+
} else {
|
|
120
|
+
disconnect = () => null;
|
|
121
|
+
operator.setReceiver((nodeTag, id, ...rest) => handlers[id](...rest));
|
|
122
|
+
resolve((methodName, ...rest) => operator[methodName](...rest)); // send()
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const peer = {
|
|
127
|
+
onError() {},
|
|
128
|
+
onLog() {},
|
|
129
|
+
health() {
|
|
130
|
+
return {peers: [], axonRoles: []};
|
|
131
|
+
},
|
|
132
|
+
async leave() {
|
|
133
|
+
await send('deleteSubscriber', nodeTag);
|
|
134
|
+
disconnect();
|
|
135
|
+
},
|
|
136
|
+
async sub(topic, handler, options) {
|
|
137
|
+
const result = await send('subscribe', topic, nodeTag, options);
|
|
138
|
+
handlers[result.id] = handler;
|
|
139
|
+
return result;
|
|
140
|
+
},
|
|
141
|
+
async unsub(topic, options) {
|
|
142
|
+
const result = await send('unsubscribe', topic, nodeTag, options);
|
|
143
|
+
delete handlers[result.id];
|
|
144
|
+
return result;
|
|
145
|
+
},
|
|
146
|
+
async pub(topic, message, options) {
|
|
147
|
+
return send('publish', topic, message, options);
|
|
148
|
+
},
|
|
149
|
+
kill(topic, msgId, options) {
|
|
150
|
+
return send('unpublish', topic, msgId, options);
|
|
151
|
+
},
|
|
152
|
+
host() {},
|
|
153
|
+
unhost() {}
|
|
154
|
+
};
|
|
155
|
+
const status = {peers: 0, ms: 0}; // fixme ms
|
|
156
|
+
return { peer, nodeIdentity, transport, status, disconnect };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
} else {
|
|
160
|
+
const protocol = await import('@axona/protocol');
|
|
161
|
+
const std = await import('@axona/protocol/std');
|
|
162
|
+
({ connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } = protocol);
|
|
163
|
+
({ stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } = std);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export { connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION };
|
|
167
|
+
export { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes }
|
|
168
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// In memory pubsub, for either client-only testing, or server-websocket testing
|
|
2
|
+
const { v4:uuidv4 } = await import('uuid');
|
|
3
|
+
const { TextEncoder, crypto, Buffer } = globalThis;
|
|
4
|
+
|
|
5
|
+
function setBucket(collection, type, topicId, subject, value) { // Set value in the collection.
|
|
6
|
+
const bucket = collection[type][topicId] ||= {};
|
|
7
|
+
bucket[subject] = value;
|
|
8
|
+
}
|
|
9
|
+
function removeBucket(collection, type, topicId, subject) { // Return the value and stop storing it.
|
|
10
|
+
const bucket = collection[type][topicId];
|
|
11
|
+
if (!bucket) return null;
|
|
12
|
+
const value = bucket[subject];
|
|
13
|
+
delete bucket[subject];
|
|
14
|
+
if (!Object.keys(bucket).length) delete collection[type][topicId];
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SUBSCRIPTION_TIMEOUT = 0; // No need, because we run deleteSubscriber on disconnect.
|
|
19
|
+
const PUBLISH_TIMEOUT = 24 * 60 * 60e3; // Delete after 24 hours.
|
|
20
|
+
const timeouts = {pub: {}, sub: {}};
|
|
21
|
+
function expire(type, topicId, subject, remover, timeout) { // Cancellably schedule remover() to fire at timeout.
|
|
22
|
+
if (!timeout) return;
|
|
23
|
+
setBucket(timeouts, type, topicId, subject, setTimeout(remover, timeout));
|
|
24
|
+
}
|
|
25
|
+
function cancel(type, topicId, subject) { // Cancel a sheduled expiration.
|
|
26
|
+
clearTimeout(removeBucket(timeouts, type, topicId, subject));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// pub maps eventName => {[subject]: storageItem, ...}, where subject is the message id. Entries purged after PUBLISH_TIMEOUT.
|
|
30
|
+
// sub maps eventName => {[subject]: ws, ...}, where subject is the subscriber id. Entries purged after SUBSCRIPTION_TIMEOUT.
|
|
31
|
+
const data = {pub: {}, sub: {}};
|
|
32
|
+
function getDataValues(type, topicId) { // For all subjects
|
|
33
|
+
return Object.values(data[type][topicId] || {});
|
|
34
|
+
}
|
|
35
|
+
function getDataEntries(type, topicId) {
|
|
36
|
+
return Object.entries(data[type][topicId] || {});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deleteSub(topicId, subject) {
|
|
40
|
+
return removeBucket(data, 'sub', topicId, subject);
|
|
41
|
+
}
|
|
42
|
+
function normalizeTopic({name, region, owner, write = 'open'} = {}) {
|
|
43
|
+
if (typeof(region) === 'string') region = parseInt(region);;
|
|
44
|
+
return {name, region, owner, write};
|
|
45
|
+
}
|
|
46
|
+
function deriveTopicId(topic) {
|
|
47
|
+
return JSON.stringify(normalizeTopic(topic)); // No need to hash in this implementation.
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let invoke;
|
|
51
|
+
export function setReceiver(receiver) {
|
|
52
|
+
invoke = receiver;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function subscribe(topicName, nodeTag, {since = 'all'}) {
|
|
56
|
+
// Axona allows multiple handlers on the same topic, but we don't use that in civildefense, and do not implement it here.
|
|
57
|
+
const topicId = deriveTopicId(topicName);
|
|
58
|
+
const id = uuidv4();
|
|
59
|
+
cancel('sub', topicId, id);
|
|
60
|
+
expire('sub', topicId, id, () => deleteSub(topicId, id), SUBSCRIPTION_TIMEOUT);
|
|
61
|
+
setBucket(data, 'sub', topicId, nodeTag, id);
|
|
62
|
+
if (since) setTimeout(() => { // invoke handler on any sticky data, but only after we have told client the subscription id.
|
|
63
|
+
let lastEnvelope = null, lastTime = 0;
|
|
64
|
+
for (const envelope of getDataValues('pub', topicId)) {
|
|
65
|
+
switch (since) {
|
|
66
|
+
case 'all':
|
|
67
|
+
invoke(nodeTag, id, envelope);
|
|
68
|
+
break;
|
|
69
|
+
case 'latest':
|
|
70
|
+
if (envelope.ts > lastTime) {
|
|
71
|
+
lastTime = envelope.ts;
|
|
72
|
+
lastEnvelope = envelope;
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
default: // Must be a timestamp
|
|
76
|
+
if (envelope.ts === since) invoke(nodeTag, id, envelope);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (lastEnvelope) invoke(nodeTag, id, lastEnvelope);
|
|
80
|
+
}, 100);
|
|
81
|
+
return {topicName, topicId, id};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function unsubscribe(topic, nodeTag, options) {
|
|
85
|
+
const topicId = deriveTopicId(topic);
|
|
86
|
+
cancel('sub', topicId, nodeTag);
|
|
87
|
+
const id = deleteSub(topicId, nodeTag);
|
|
88
|
+
return {ok: true, id}; // Axona doesn't return the id(s) of the subscription(s), but it is convenient for us to do so.
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function deleteSubscriber(nodeTag) {
|
|
92
|
+
for (const topicId in data.sub) {
|
|
93
|
+
const keySubs = data.sub[topicId];
|
|
94
|
+
for (const [subject, value] of Object.entries(keySubs)) {
|
|
95
|
+
if (nodeTag === subject) deleteSub(topicId, subject, keySubs);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hasBuffer = typeof Buffer !== 'undefined';
|
|
101
|
+
let toHex = hasBuffer ? u8 => Buffer.from(u8).toString('hex') : u8 => u8.toHex();
|
|
102
|
+
export async function publish(topic, message, {signWith}) {
|
|
103
|
+
const topicId = deriveTopicId(topic);
|
|
104
|
+
const signerPubkey = signWith?.authorId || undefined;
|
|
105
|
+
const payload = JSON.stringify({message, publisher: signerPubkey});
|
|
106
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload));
|
|
107
|
+
const msgId = toHex(new Uint8Array(hash));
|
|
108
|
+
const envelope = {msgId, topic, ts: Date.now(), message, signerPubkey};
|
|
109
|
+
for (const [nodeTag, id] of getDataEntries('sub', topicId)) invoke(nodeTag, id, envelope);
|
|
110
|
+
setBucket(data, 'pub', topicId, msgId, envelope);
|
|
111
|
+
expire('pub', topicId, msgId, () => removeBucket(data, 'pub', topicId, msgId), PUBLISH_TIMEOUT);
|
|
112
|
+
return msgId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function unpublish(topic, msgId, {signWith}) {
|
|
116
|
+
const topicId = deriveTopicId(topic);
|
|
117
|
+
cancel('pub', topicId, msgId);
|
|
118
|
+
const envelope = removeBucket(data, 'pub', topicId, msgId);
|
|
119
|
+
if (!envelope) return {ok: false}; // we didn't have it.
|
|
120
|
+
envelope.deleted = true;
|
|
121
|
+
envelope.message = null;
|
|
122
|
+
for (const [nodeTag, id] of getDataEntries('sub', topicId)) invoke(nodeTag, id, envelope);
|
|
123
|
+
return {ok: true};
|
|
124
|
+
}
|
package/public/javascripts/s2.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { s2 } from 's2js';
|
|
1
|
+
import { s2, s1, r1 } from 's2js';
|
|
2
2
|
const { cellid, LatLng, Point, Cell, Cap, RegionCoverer } = s2;
|
|
3
|
+
import { cellHex } from './versions.js';
|
|
4
|
+
const { BigInt } = globalThis;
|
|
3
5
|
|
|
4
6
|
// s2 defines non-overlapping cells that completely cover the globe, at several different levels of cell-size.
|
|
5
7
|
// Each cell, at each level, has its own unique id, which we use a pub/sub key.
|
|
@@ -10,18 +12,30 @@ const { cellid, LatLng, Point, Cell, Cap, RegionCoverer } = s2;
|
|
|
10
12
|
// Meanwhile as the user changes the area being shown, we subscribe to whatever cells we need in order to cover the display
|
|
11
13
|
// area without overlapping cells.
|
|
12
14
|
|
|
13
|
-
export const MIN_LEVEL =
|
|
15
|
+
export const MIN_LEVEL = 3; // Corresponds to the top level Axona regions.
|
|
14
16
|
const MAX_S2_LEVEL = 30; // The leaf level that Cell.fromPoint operates at.
|
|
15
17
|
const MAX_MAP_LEVEL = 17; // The max level that findCoverCellsByCenterAndRadius will use on our maps.
|
|
16
18
|
|
|
17
19
|
const EARTH_RADIUS_METERS = 6371e3;
|
|
18
20
|
|
|
19
|
-
export function getPointInCell(cellId) { // answer [lat, lng] in degrees
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
export function getPointInCell(cellId) { // answer [lat, lng] in degrees from a BigInt
|
|
22
|
+
// CAUTION: This is intended for s2 level 3 or finer, and won't always work symmetrically for our top-level regions.
|
|
23
|
+
// e.g. getContainingCells(...getPointInCell( cell for 0x47 )][0] is 0x46!
|
|
24
|
+
let center = s2.cellid.latLng(cellId);
|
|
25
|
+
return [s1.angle.degrees(center.lat), s1.angle.degrees(center.lng)];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getCellSubdivision(cell) {
|
|
29
|
+
return cellid.children(cell);
|
|
30
|
+
}
|
|
31
|
+
function getCellLevel(cell) {
|
|
32
|
+
return cellid.level(cell);
|
|
33
|
+
}
|
|
34
|
+
function getFace(cell) {
|
|
35
|
+
return cellid.face(cell);
|
|
36
|
+
}
|
|
37
|
+
export function getSubdivision(hexString) {
|
|
38
|
+
return getCellSubdivision(BigInt('0x' + hexString)).map(childCell => cellHex(childCell));
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
// Return a list of the cell ids that contain the point.
|
|
@@ -37,41 +51,22 @@ export function getContainingCells(lat, lng) {
|
|
|
37
51
|
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
52
|
}
|
|
39
53
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
-
}
|
|
54
|
+
export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, full = false,
|
|
55
|
+
options:{minLevel = MIN_LEVEL, maxLevel = MAX_MAP_LEVEL, maxCells = 12} = {}}) {
|
|
56
|
+
// Return a list-like object of cell ids that cover the specified range.
|
|
57
|
+
// The lat/lng won't work well for the full map, so an exact full map can be requested, overriding that lat/lng.
|
|
49
58
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
const
|
|
59
|
+
// There are a lot of ways that seem like they do this, but it is very easy to find something that works for a few cases,
|
|
60
|
+
// but misses cells in some circumstances, so be wary about rewriting this.
|
|
61
|
+
const lo = s2.LatLng.fromDegrees(minLat, Math.max(minLng, -180));
|
|
62
|
+
const hi = s2.LatLng.fromDegrees(maxLat, Math.max(maxLng, 190));
|
|
53
63
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
64
|
+
const rect = full ? s2.Rect.fullRect() : new s2.Rect(
|
|
65
|
+
new r1.Interval(lo.lat, hi.lat),
|
|
66
|
+
new r1.Interval(lo.lng, hi.lng)
|
|
67
|
+
);
|
|
69
68
|
|
|
70
|
-
const
|
|
71
|
-
|
|
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);
|
|
69
|
+
const coverer = new RegionCoverer({minLevel, maxLevel, maxCells});
|
|
70
|
+
return coverer.covering(rect); // a CellUnion — array-like of bigint cell IDs, already normalized/minimal
|
|
75
71
|
}
|
|
76
72
|
|
|
77
|
-
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const { Request, Response, URL, localStorage, BroadcastChannel } = globalThis;
|
|
2
2
|
import { appVersion } from './versions.js';
|
|
3
|
-
import { resetInactivityTimer, clickTip } from './main.js';
|
|
3
|
+
import { resetInactivityTimer, clickTip, closeAbout } from './main.js';
|
|
4
4
|
import { openDisplay } from './display.js';
|
|
5
|
-
import { go } from './alert.js';
|
|
5
|
+
import { go, getShareableURL } from './alert.js';
|
|
6
6
|
import { Int } from './translations.js';
|
|
7
7
|
|
|
8
8
|
/*
|
|
@@ -73,12 +73,13 @@ function newVersionAvailable(newVersion) {
|
|
|
73
73
|
}
|
|
74
74
|
async function installUpdate(event, newVersion) {
|
|
75
75
|
event.stopPropagation();
|
|
76
|
-
event.target.textContent =
|
|
76
|
+
event.target.textContent = Int`Installing...`; // In case there is some delay, tell the user what we're trying to do. Will be cleared with reload.
|
|
77
77
|
event.target.disabled = true;
|
|
78
78
|
await caches.delete(appVersion); // Must be before cacheSource, or we'll just recache the same files!
|
|
79
79
|
await cacheSource(newVersion);
|
|
80
|
+
closeAbout();
|
|
80
81
|
// Reload, but convince all browsers to re-"fetch" (through the new service worker that is now running).
|
|
81
|
-
const url =
|
|
82
|
+
const url = getShareableURL(null, []);
|
|
82
83
|
url.searchParams.set('v', newVersion); // Preserving any other searchParams.
|
|
83
84
|
// For any other tabs in THIS browser:
|
|
84
85
|
new BroadcastChannel('site_control').postMessage({method: 'reload', params: url.href});
|
|
@@ -52,6 +52,7 @@ const translations = {
|
|
|
52
52
|
['does not support notifications on WebViews embedded in other programs. Please use CivilDefense.io in native']: {es: "no admite notificaciones en WebViews integrados en otros programas. Por favor, utilice CivilDefense.io en el navegador nativo"},
|
|
53
53
|
['Apple only supports mobile notifications for web pages that have been']: {es: "Apple solo admite notificaciones móviles para páginas web que hayan sido"},
|
|
54
54
|
['installed to the home screen']: {es: "instaladoi en la pantalla de inicio"},
|
|
55
|
+
['Installing...']: {es: "Instalando..."},
|
|
55
56
|
['Enable notifications']: {es: "Habilitar notificaciones"},
|
|
56
57
|
['Allow notifications']: {es: "Permitir notificaciones"},
|
|
57
58
|
['Permissions can be re-enabled through the']: {es: "Los permisos se pueden volver a habilitar a través de"},
|
|
@@ -3,10 +3,11 @@ export const appVersion = pkg.version; // Overall semver of app. Used in display
|
|
|
3
3
|
export const dataVersion = globalThis.process?.env.EVENT_VERSION || appVersion.split('.')[0]; // Compatability differentiator used below.
|
|
4
4
|
|
|
5
5
|
export function stripLeadingEmoji(string) { // Return string without any leading emoji (which might be of varying
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
// length) followed by an optional emoji break character and any whitespace.
|
|
7
|
+
// {Extended_Pictographic} is often recommended instead of {Emoji} as the latter includes numbers
|
|
8
|
+
// and symbols. However, the former misses, e.g., the flag emojis.
|
|
9
|
+
return string.replace(/^\p{Emoji}*\uFE0F?\s*/u, '') ||
|
|
10
|
+
(string.endsWith(' ') ? '' : string); // All emoji counts, but if ending with space, it is the start of an emoji + \s + tag for which just the tag counts.
|
|
10
11
|
};
|
|
11
12
|
export function canonicalTag(tag) { // A string representing tag, without the (leading) emoji if any.
|
|
12
13
|
return stripLeadingEmoji(tag).toLowerCase();
|
|
@@ -18,6 +19,16 @@ export function agentPersistKey(metadataType, agentTag) { // A label for looking
|
|
|
18
19
|
export function agentTopic(metadataType, agentTag) { // Return topic name for public info about agent specified by tag.
|
|
19
20
|
return `public:${dataVersion}:${agentPersistKey(metadataType, agentTag)}`;
|
|
20
21
|
}
|
|
22
|
+
export function cellHex(cellid) { // Convert cellid BigInt to properly padded hex.
|
|
23
|
+
return cellid.toString(16).padStart(16, '0');
|
|
24
|
+
}
|
|
21
25
|
export function alertTopic(cellid, tag) { // Return topic name for public info about specified tag in cellid.
|
|
22
|
-
return `civildefense.io:${dataVersion}:${cellid}:${canonicalTag(tag)}`;
|
|
26
|
+
return `civildefense.io:${dataVersion}:${cellHex(cellid)}:${canonicalTag(tag)}`;
|
|
27
|
+
}
|
|
28
|
+
export function topicCell(topicName) { // Return the cell that is baked in to the topic.
|
|
29
|
+
// Note that canonicalTag(tag) may contain a colon, but dataVersion must not.
|
|
30
|
+
return topicName.match(/civildefense.io:[^:]+:([a-z0-9]+):/)[1];
|
|
31
|
+
}
|
|
32
|
+
export function topicRegion(topicName) { // Return the region that is baked in to the topic.
|
|
33
|
+
return '0x' + topicCell(topicName).slice(0, 2);
|
|
23
34
|
}
|
package/public/service-worker.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { Request, Response, URL, clients} = self;
|
|
2
2
|
// Little point in trying to automatically pull this through package.json, as we need a byte change in THIS file to trigger a new worker.
|
|
3
|
-
const serviceVersion = '4.5.
|
|
3
|
+
const serviceVersion = '4.5.21';
|
|
4
4
|
|
|
5
5
|
const cacheList = [ // The files we need.
|
|
6
6
|
"/",
|
|
@@ -21,6 +21,8 @@ const cacheList = [ // The files we need.
|
|
|
21
21
|
"javascripts/translations.js",
|
|
22
22
|
"javascripts/service-manager.js",
|
|
23
23
|
"javascripts/p2pWebNetwork.js",
|
|
24
|
+
"javascripts/protocol.js",
|
|
25
|
+
"javascripts/pubsub.js",
|
|
24
26
|
|
|
25
27
|
"stylesheets/style.css",
|
|
26
28
|
|
|
@@ -34,6 +36,7 @@ const cacheList = [ // The files we need.
|
|
|
34
36
|
|
|
35
37
|
"axona-protocol/src/index.js",
|
|
36
38
|
"axona-protocol/src/errors.js",
|
|
39
|
+
"axona-protocol/src/connect.js",
|
|
37
40
|
"axona-protocol/src/bridgeDirectory.js",
|
|
38
41
|
"axona-protocol/src/contracts/Transport.js",
|
|
39
42
|
"axona-protocol/src/contracts/DHT.js",
|
|
@@ -49,6 +52,18 @@ const cacheList = [ // The files we need.
|
|
|
49
52
|
"axona-protocol/src/dht/Subscription.js",
|
|
50
53
|
"axona-protocol/src/pubsub/AxonaManager.js",
|
|
51
54
|
"axona-protocol/src/pubsub/authorClass.js",
|
|
55
|
+
"axona-protocol/src/pubsub/ids.js",
|
|
56
|
+
"axona-protocol/src/pubsub/dispatch.js",
|
|
57
|
+
"axona-protocol/src/pubsub/durability.js",
|
|
58
|
+
"axona-protocol/src/pubsub/rootClaim.js",
|
|
59
|
+
"axona-protocol/src/pubsub/constants.js",
|
|
60
|
+
"axona-protocol/src/pubsub/topicStore.js",
|
|
61
|
+
"axona-protocol/src/pubsub/rootElection.js",
|
|
62
|
+
"axona-protocol/src/pubsub/syncEngine.js",
|
|
63
|
+
"axona-protocol/src/pubsub/repairPlane.js",
|
|
64
|
+
"axona-protocol/src/pubsub/wireHandlers.js",
|
|
65
|
+
"axona-protocol/src/pubsub/writeFlight.js",
|
|
66
|
+
"axona-protocol/src/pubsub/ackProof.js",
|
|
52
67
|
"axona-protocol/src/pubsub/kill.js",
|
|
53
68
|
"axona-protocol/src/pubsub/touch.js",
|
|
54
69
|
"axona-protocol/src/pubsub/post.js",
|
|
@@ -166,20 +181,20 @@ self.addEventListener('fetch', event => {
|
|
|
166
181
|
async function cacheSource(version, event) { // Cache source in the given version.
|
|
167
182
|
console.log(`service-worker ${serviceVersion} is caching source in cache ${version}.`);
|
|
168
183
|
const cache = await caches.open(version);
|
|
169
|
-
|
|
170
|
-
|
|
184
|
+
await Promise.all(cacheList.map(file => cache.add(file)
|
|
185
|
+
.catch(error => console.error(file + ' ' + error.message))));
|
|
171
186
|
|
|
172
187
|
await Promise.all([
|
|
173
188
|
// These are referenced within material web, but missing. Turns out we don't need them,
|
|
174
189
|
// but let's cache empty responses to keep the console cleaner.
|
|
175
|
-
"https://esm.run/npm/lit@3.3.1/+esm",
|
|
176
190
|
"https://esm.run/npm/tslib@2.8.1/+esm",
|
|
177
|
-
"https://esm.run/npm/lit@3.3.
|
|
178
|
-
"https://esm.run/npm/lit@3.3.
|
|
179
|
-
"https://esm.run/npm/lit@3.3.
|
|
180
|
-
"https://esm.run/npm/lit@3.3.
|
|
181
|
-
"https://esm.run/npm/lit@3.3.
|
|
182
|
-
"https://esm.run/npm/lit@3.3.
|
|
191
|
+
"https://esm.run/npm/lit@3.3.3/+esm",
|
|
192
|
+
"https://esm.run/npm/lit@3.3.3/static-html.js/+esm",
|
|
193
|
+
"https://esm.run/npm/lit@3.3.3/decorators.js/+esm",
|
|
194
|
+
"https://esm.run/npm/lit@3.3.3/directives/style-map.js/+esm",
|
|
195
|
+
"https://esm.run/npm/lit@3.3.3/directives/class-map.js/+esm",
|
|
196
|
+
"https://esm.run/npm/lit@3.3.3/directives/when.js/+esm",
|
|
197
|
+
"https://esm.run/npm/lit@3.3.3/directives/live.js/+esm",
|
|
183
198
|
].map(url => cache.put(new Request(url),
|
|
184
199
|
new Response("", {headers: { "Content-Type": "text/javascript" }}))));
|
|
185
200
|
return version;
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
--md-sys-shape-corner-full: 12px;
|
|
17
17
|
|
|
18
18
|
--about-image-size: 80px;
|
|
19
|
+
--combobox-minWidth: 125px;
|
|
19
20
|
|
|
20
21
|
/* --md-sys-color-tertiary-container: blue; */
|
|
21
22
|
/* --md-sys-color-background: yellow; */
|
|
@@ -196,22 +197,26 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
196
197
|
.combobox {
|
|
197
198
|
position: relative;
|
|
198
199
|
--textFieldHeight: 34px;
|
|
199
|
-
--minWidth: 125px;
|
|
200
200
|
}
|
|
201
201
|
.combobox-listbox {
|
|
202
202
|
padding: 5px;
|
|
203
203
|
position: absolute;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
max-height:
|
|
207
|
-
width: fit-content;
|
|
208
|
-
min-width: var(--minWidth);
|
|
204
|
+
right: 5px;
|
|
205
|
+
bottom: 55px;
|
|
206
|
+
max-height: 45dvh; /* leaving room for portrait keyboard. landscape keyboard has no room at all *\/ */
|
|
209
207
|
overflow-y: auto;
|
|
210
|
-
|
|
208
|
+
width: fit-content;
|
|
209
|
+
min-width: var(--combobox-minWidth);
|
|
210
|
+
z-index: 1000;
|
|
211
211
|
background: rgba(255, 255, 255, 0.95);
|
|
212
212
|
border-top-left-radius: 12px;
|
|
213
213
|
border-top-right-radius: 12px;
|
|
214
214
|
}
|
|
215
|
+
@media (orientation: landscape) and (pointer: coarse) {
|
|
216
|
+
.combobox-listbox {
|
|
217
|
+
display: none !important;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
215
220
|
.combobox-option {
|
|
216
221
|
font-size: 0.95rem;
|
|
217
222
|
color: var(--md-sys-color-primary);
|
|
@@ -238,12 +243,13 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
238
243
|
background: #B8C2D7;
|
|
239
244
|
}
|
|
240
245
|
.watching-hashtags md-filled-text-field.newtag {
|
|
246
|
+
anchor-name: --combobox-newtag;
|
|
241
247
|
--md-filled-field-top-space: 0px;
|
|
242
248
|
--md-filled-field-bottom-space: 0px;
|
|
243
249
|
--md-filled-text-field-leading-space: 8px;
|
|
244
250
|
--md-filled-text-field-trailing-space: 0px;
|
|
245
|
-
height:
|
|
246
|
-
width: var(--minWidth);
|
|
251
|
+
height: 34px;
|
|
252
|
+
width: var(--combobox-minWidth);
|
|
247
253
|
}
|
|
248
254
|
.alert-marker {
|
|
249
255
|
text-shadow: 8px -3px 10px rgb(0 0 0 / 60%);
|
|
@@ -323,6 +329,7 @@ md-filter-chip minidenticon-svg {
|
|
|
323
329
|
md-outlined-icon-button minidenticon-svg, md-outlined-icon-button img {
|
|
324
330
|
height: 40px;
|
|
325
331
|
width: 40px;
|
|
332
|
+
object-fit: cover;
|
|
326
333
|
border-radius: 12px;
|
|
327
334
|
}
|
|
328
335
|
.alert span minidenticon-svg {
|
|
@@ -388,9 +395,9 @@ md-menu {
|
|
|
388
395
|
md-outlined-button {
|
|
389
396
|
padding: 10px;
|
|
390
397
|
}
|
|
391
|
-
.attribution > div:last-child > span {
|
|
392
|
-
|
|
393
|
-
}
|
|
398
|
+
/* .attribution > div:last-child > span { */
|
|
399
|
+
/* text-wrap: nowrap; */
|
|
400
|
+
/* } */
|
|
394
401
|
md-menu-item minidenticon-svg {
|
|
395
402
|
display: inline-block;
|
|
396
403
|
height: 20px;
|
|
@@ -424,7 +431,7 @@ md-menu-item minidenticon-svg {
|
|
|
424
431
|
.attribution-metadata {
|
|
425
432
|
display: flex;
|
|
426
433
|
flex-direction: column;
|
|
427
|
-
align-items: end;
|
|
434
|
+
/* align-items: end; */
|
|
428
435
|
}
|
|
429
436
|
.alert {
|
|
430
437
|
font-family: roboto;
|