@yz-social/civildefense.io 4.5.22 → 4.5.28
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/README.md +1 -1
- package/package.json +3 -2
- package/public/about/checkpoint.jpg +0 -0
- package/public/about/en.html +13 -6
- package/public/about/es.html +62 -40
- package/public/about/radio-europe.jpg +0 -0
- package/public/about/streets-of-minneapolis.jpg +0 -0
- package/public/about/style.css +1 -0
- package/public/index.html +11 -0
- package/public/javascripts/alert.js +380 -155
- package/public/javascripts/conversation.js +5 -3
- package/public/javascripts/hashtags.js +16 -18
- package/public/javascripts/main.js +12 -0
- package/public/javascripts/p2pWebNetwork.js +4 -2
- package/public/javascripts/protocol.js +10 -8
- package/public/javascripts/pubsub.js +44 -22
- package/public/javascripts/s2.js +34 -18
- package/public/javascripts/translations.js +11 -7
- package/public/javascripts/versions.js +9 -3
- package/public/service-worker.js +5 -2
- package/public/stylesheets/style.css +109 -0
- package/server/app.js +30 -10
- package/server/location.save.json +1 -1
- package/server/websocket.js +8 -5
|
@@ -19,7 +19,7 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
|
|
|
19
19
|
for (const key in properties) {
|
|
20
20
|
const existing = this[key];
|
|
21
21
|
const proposed = properties[key];
|
|
22
|
-
if (JSON.stringify(existing) !== JSON.stringify(proposed)) throw new Error(`Cannot update '${key}' from ${existing} to ${proposed}.`);
|
|
22
|
+
if (JSON.stringify(existing) !== JSON.stringify(proposed)) throw new Error(`Cannot update '${key}' in ${this.constructor.name} ${this.tag} from ${JSON.stringify(existing)} to ${JSON.stringify(proposed)}.`);
|
|
23
23
|
}
|
|
24
24
|
return this;
|
|
25
25
|
}
|
|
@@ -32,13 +32,15 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
|
|
|
32
32
|
|
|
33
33
|
static async ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
|
|
34
34
|
const {tag, payload, ...rest} = data;
|
|
35
|
-
let item = container.getItem(tag);
|
|
35
|
+
let item = container.getItem(tag); // existing item
|
|
36
36
|
if (!payload) return item?.destroy();
|
|
37
|
+
|
|
38
|
+
// update or create item.
|
|
37
39
|
if (item) item = await item.update(data);
|
|
38
40
|
else item = await (new kind().initialize({...data, container}));
|
|
39
41
|
|
|
40
42
|
if (!item) return container.removeItem(tag)?.destroy();
|
|
41
|
-
return container.setItem(tag, item);
|
|
43
|
+
return container.setItem(item.tag, item); // initialize() may come up with a better tag.
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -76,16 +76,18 @@ export const Hashtags = {
|
|
|
76
76
|
}
|
|
77
77
|
return extended;
|
|
78
78
|
},
|
|
79
|
-
getAll() { // List of all the user's hashtags.
|
|
79
|
+
getAll() { // List of all the user's extended hashtags.
|
|
80
80
|
return Object.keys(this.hashtags);
|
|
81
81
|
},
|
|
82
82
|
getSubscribe() { // Return a list of the hashtags to which the user intendeds to subscribe.
|
|
83
83
|
return this.getAll().filter(tag => this.hashtags[tag]);
|
|
84
84
|
},
|
|
85
|
-
isSubscribed(key) {
|
|
86
|
-
|
|
85
|
+
isSubscribed(key) { // Is this user subscribed to some form of this key.
|
|
86
|
+
const canonical = canonicalTag(key); // no emoji, lower case.
|
|
87
|
+
const ourExtended = this.canonical2extended[canonical]; // our current version, if any
|
|
88
|
+
return this.hashtags[ourExtended];
|
|
87
89
|
},
|
|
88
|
-
isPublish(key) {
|
|
90
|
+
isPublish(key) { // Is this extended tag (in the user's preferred presentation) the user's current publishing tag?
|
|
89
91
|
return this.hashtags[key] === 'pub';
|
|
90
92
|
},
|
|
91
93
|
backupPublisher: false,
|
|
@@ -123,7 +125,7 @@ export const Hashtags = {
|
|
|
123
125
|
if (resetSubscriptions) {
|
|
124
126
|
// We destroy unsubscribed markers right away, because we don't want the user to have to wait and wonder why they're still displayed.
|
|
125
127
|
// If there are alerts in flight, they will be rejected by Alert initialize because we will have already turned off the sub.
|
|
126
|
-
|
|
128
|
+
Alert.items.forEach(wrapper => this.isSubscribed(wrapper.hashtag) || wrapper.destroy());
|
|
127
129
|
Alert.updateSubscriptions();
|
|
128
130
|
}
|
|
129
131
|
},
|
|
@@ -158,6 +160,7 @@ export const Hashtags = {
|
|
|
158
160
|
newtag.removeAttribute('aria-activedescendant');
|
|
159
161
|
newtag.value = '';
|
|
160
162
|
this.activeIndex = -1;
|
|
163
|
+
this.selectors = [];
|
|
161
164
|
},
|
|
162
165
|
openSelector() { // Open the autocomplete tag selector
|
|
163
166
|
const { listbox, newtag } = this;
|
|
@@ -186,8 +189,9 @@ export const Hashtags = {
|
|
|
186
189
|
acceptTag() { // Add the new hashtag.
|
|
187
190
|
resetInactivityTimer();
|
|
188
191
|
let tag = this.newtag?.value // Get into standard form, but do not strip emoji or case into canonical yet.
|
|
189
|
-
.replace(/^#/, '')
|
|
190
|
-
.replace(
|
|
192
|
+
.replace(/^#/, '') // No leading hash
|
|
193
|
+
.replace(':', '.') // Replace colon with some other separator.
|
|
194
|
+
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
|
|
191
195
|
.normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
|
|
192
196
|
Alert.closePopup();
|
|
193
197
|
if (!tag) return;
|
|
@@ -226,16 +230,8 @@ export const Hashtags = {
|
|
|
226
230
|
item.toLowerCase().includes(matchString)
|
|
227
231
|
);
|
|
228
232
|
|
|
229
|
-
if (matchString) {
|
|
230
|
-
|
|
231
|
-
li.className = 'combobox-empty combobox-option';
|
|
232
|
-
li.textContent = `Create tag "${query}"`;
|
|
233
|
-
li.onpointerdown = event => {
|
|
234
|
-
event.preventDefault();
|
|
235
|
-
event.stopPropagation();
|
|
236
|
-
this.acceptTag();
|
|
237
|
-
};
|
|
238
|
-
listbox.appendChild(li);
|
|
233
|
+
if (matchString && !this.selectors.includes(query)) {
|
|
234
|
+
this.selectors.unshift(query);
|
|
239
235
|
}
|
|
240
236
|
this.selectors.forEach((item, i) => {
|
|
241
237
|
const li = document.createElement('li');
|
|
@@ -321,6 +317,7 @@ export const Hashtags = {
|
|
|
321
317
|
clickTip(newtag, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
|
|
322
318
|
event.stopPropagation();
|
|
323
319
|
Alert.closePopup();
|
|
320
|
+
resetInactivityTimer();
|
|
324
321
|
this.renderSelector('');
|
|
325
322
|
if (navigator.maxTouchPoints <= 1) { // Only when no multi-touch. On-screen keyboard makes it shoot off the top.
|
|
326
323
|
showMessage(Int`Type a new topic name to see any alerts on the map with this topic.`, 'instructions');
|
|
@@ -360,13 +357,14 @@ export const Hashtags = {
|
|
|
360
357
|
if (this.activeIndex < 0) this.setActive(0);
|
|
361
358
|
else this.selectValue(this.selectors[this.activeIndex]); // Otherwise select what is active.
|
|
362
359
|
break;
|
|
360
|
+
default:
|
|
361
|
+
this.activeIndex = -1;
|
|
363
362
|
}
|
|
364
363
|
};
|
|
365
364
|
newtag.oninput = () => this.renderSelector(newtag.value);
|
|
366
365
|
// When we click on the listbox, the browser will first blur newtag, and then
|
|
367
366
|
// we would not get the click! So here we delay closing a bit.
|
|
368
367
|
newtag.onblur = () => setTimeout(() => this.closeSelector(), 200);
|
|
369
|
-
newtag.onchange = () => this.acceptTag();
|
|
370
368
|
},
|
|
371
369
|
remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
|
|
372
370
|
delete this.hashtags[chip.label];
|
|
@@ -24,6 +24,8 @@ var inactivityTimer = null, reconnectCountdown, networkPromise = null;
|
|
|
24
24
|
export { networkPromise };
|
|
25
25
|
export async function resetInactivityTimer(clearMessage = true) { // if !network, initialize(false), else disconnect after INACTIVITY_SECONDSif not restarted
|
|
26
26
|
//console.log('resetInactivityTimer, networkPromise:', networkPromise);
|
|
27
|
+
document.body.classList.toggle('firstConversation', false);
|
|
28
|
+
document.body.classList.toggle('firstPublish', false);
|
|
27
29
|
if (clearMessage) showMessage('');
|
|
28
30
|
clearTimeout(inactivityTimer);
|
|
29
31
|
clearInterval(reconnectCountdown);
|
|
@@ -46,6 +48,11 @@ export function clickTip(elementOrQuerySelector, text, clickHandler) { // toolti
|
|
|
46
48
|
tooltip(element, text);
|
|
47
49
|
element.onclick = clickHandler;
|
|
48
50
|
}
|
|
51
|
+
export function getText(querySelector) {
|
|
52
|
+
const element = document.querySelector(querySelector);
|
|
53
|
+
if (!element) throw new Error(`No element found for getText('${querySelector}').`);
|
|
54
|
+
return element.textContent;
|
|
55
|
+
}
|
|
49
56
|
|
|
50
57
|
function isWebView() { return /CriOS|(WebView|wv|(iPhone|iPod|iPad)(?!.*Safari))/.test(navigator.userAgent); }
|
|
51
58
|
function isApple() { return navigator.platform.startsWith('Mac') || ['iPhone', 'iPad'].includes(navigator.platform); }
|
|
@@ -392,6 +399,11 @@ initText('#describePublic');
|
|
|
392
399
|
initText('#describeSystem');
|
|
393
400
|
initText('#pickLabels');
|
|
394
401
|
initText('#wipe');
|
|
402
|
+
initText('.firstConversation .teach.correspondent');
|
|
403
|
+
initText('.firstPublish .teach.correspondent');
|
|
404
|
+
initText('.teach.share');
|
|
405
|
+
initText('.teach.attach');
|
|
406
|
+
initText('.teach.changeHashtag');
|
|
395
407
|
|
|
396
408
|
tooltip('#learnMore', Int`Click to see more information in another tab about what you can do with this app and how it is resistant to tracking, censorship, and takedown.`);
|
|
397
409
|
tooltip('#aboutAnyone2', Int`Click to see the source code and documentation for serving copies of this app in another tab.`);
|
|
@@ -45,6 +45,8 @@ export class P2PWebNetwork {
|
|
|
45
45
|
onDisconnect: network.detached,
|
|
46
46
|
author: false
|
|
47
47
|
});
|
|
48
|
+
infoLogger ||= null;
|
|
49
|
+
debugLogger ||= null;
|
|
48
50
|
Object.assign(network, {infoLogger, debugLogger, disconnector: disconnect, transport, nodeIdentity, peer});
|
|
49
51
|
|
|
50
52
|
network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(location.lat, location.lng).toString(16)}.`);
|
|
@@ -65,7 +67,7 @@ export class P2PWebNetwork {
|
|
|
65
67
|
async disconnect(debugLogger = this.debugLogger) { // Politely close network connection.
|
|
66
68
|
const health = this.peer.health();
|
|
67
69
|
await this.disconnector();
|
|
68
|
-
if (debugLogger)
|
|
70
|
+
if (debugLogger) this.debug('health at disconnect', health);
|
|
69
71
|
else this.shortHealth(health);
|
|
70
72
|
this.resetStatePromises();
|
|
71
73
|
}
|
|
@@ -251,7 +253,7 @@ export class P2PWebNetwork {
|
|
|
251
253
|
}
|
|
252
254
|
shortHealth(health = this.peer.health()) { // Info-report a short form of health.
|
|
253
255
|
const roots = health.axonRoles.filter(r => r.isRoot);
|
|
254
|
-
this.info(`disconnected with connections ${health.peers.map(P2PWebNetwork.short)}\nand ${roots.length ? `roots ${roots.map(r => P2PWebNetwork.short(r.topic))}.` : 'no roots.'}`);
|
|
256
|
+
this.info(`disconnected with ${health.peers.length} connections: ${health.peers.map(P2PWebNetwork.short)}\nand ${roots.length ? `roots ${roots.map(r => P2PWebNetwork.short(r.topic))}.` : 'no roots.'}`);
|
|
255
257
|
}
|
|
256
258
|
inRegionConnections(health = this.peer.health()) {
|
|
257
259
|
const region = this.nodeIdentity.id.slice(0, 2);
|
|
@@ -6,7 +6,7 @@ let connect, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNE
|
|
|
6
6
|
// dht 1 -> Axona
|
|
7
7
|
// dht 0 -> server
|
|
8
8
|
// dht -1 -> in-memory on client only
|
|
9
|
-
const defaultDHT = 1;
|
|
9
|
+
const defaultDHT = globalThis.process ? 1 : 0; // Browser defaults to no Axona. Server nodes and alert-bot default to Axona.
|
|
10
10
|
export const dht = parseInt((globalThis.process ?
|
|
11
11
|
globalThis.process.env.DHT :
|
|
12
12
|
new URL(globalThis.location).searchParams.get('dht')) ?? defaultDHT);
|
|
@@ -14,7 +14,7 @@ export const dht = parseInt((globalThis.process ?
|
|
|
14
14
|
if (dht < 1) {
|
|
15
15
|
|
|
16
16
|
const { v4:uuidv4 } = await import('uuid');
|
|
17
|
-
const {
|
|
17
|
+
const { getSmallestCellId, getPointInCell } = await import('./s2.js');
|
|
18
18
|
const { cellHex } = await import('./versions.js');
|
|
19
19
|
const operator = await import('./pubsub.js');
|
|
20
20
|
|
|
@@ -38,8 +38,8 @@ if (dht < 1) {
|
|
|
38
38
|
return {authorId: tag};
|
|
39
39
|
};
|
|
40
40
|
geoCellId = (lat, lng) => {
|
|
41
|
-
const
|
|
42
|
-
const hex = cellHex(
|
|
41
|
+
const cellid = getSmallestCellId(lat, lng);
|
|
42
|
+
const hex = cellHex(cellid);
|
|
43
43
|
const sliced = hex.slice(0, 2);
|
|
44
44
|
return parseInt(sliced, 16); // The worst way to do this.
|
|
45
45
|
};
|
|
@@ -97,9 +97,9 @@ if (dht < 1) {
|
|
|
97
97
|
const subHandler = handlers[tag];
|
|
98
98
|
const inFlightResolver = inFlight[tag];
|
|
99
99
|
|
|
100
|
-
if ((!subHandler && !inFlightResolver) || // debug
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
// if ((!subHandler && !inFlightResolver) || // debug
|
|
101
|
+
// ((typeof(subHandler) !== 'function') && (typeof(inFlightResolver) !== 'function')))
|
|
102
|
+
// console.warn('no handler or request', {tag, rest, subHandler, inFlightResolver, handlers, inFlight});
|
|
103
103
|
|
|
104
104
|
if (subHandler) return subHandler(...rest);
|
|
105
105
|
delete inFlight[tag];
|
|
@@ -107,7 +107,7 @@ if (dht < 1) {
|
|
|
107
107
|
};
|
|
108
108
|
socket.onopen = () => {
|
|
109
109
|
if (socket.readyState !== WebSocket.OPEN) return; // You would think that can't happen, but...
|
|
110
|
-
resolve((...rest) => { // send()
|
|
110
|
+
resolve((...rest) => { // Promise the send() function.
|
|
111
111
|
const tag = uuidv4();
|
|
112
112
|
const {promise, resolve} = Promise.withResolvers();
|
|
113
113
|
inFlight[tag] = resolve;
|
|
@@ -154,10 +154,12 @@ if (dht < 1) {
|
|
|
154
154
|
kill(topic, msgId, options) {
|
|
155
155
|
return send('unpublish', topic, msgId, options);
|
|
156
156
|
},
|
|
157
|
+
lookup() { return {}; },
|
|
157
158
|
host() {},
|
|
158
159
|
unhost() {}
|
|
159
160
|
};
|
|
160
161
|
const status = {peers: 0, ms: 0}; // fixme ms
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 1e3)); // Simulate finding peers, and give the app some time to do stuff.
|
|
161
163
|
return { peer, nodeIdentity, transport, status, disconnect };
|
|
162
164
|
};
|
|
163
165
|
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
// In memory pubsub, for either client-only testing, or server-websocket testing
|
|
2
2
|
const { v4:uuidv4 } = await import('uuid');
|
|
3
3
|
const { TextEncoder, crypto, Buffer } = globalThis;
|
|
4
|
+
const publicationRolloverLimit = 1000;
|
|
4
5
|
|
|
5
6
|
function setBucket(collection, type, topicId, subject, value) { // Set value in the collection.
|
|
6
7
|
const bucket = collection[type][topicId] ||= {};
|
|
8
|
+
if ((collection === data) && (type === 'pub')) {
|
|
9
|
+
const keys = Object.keys(bucket);
|
|
10
|
+
const size = keys.length;
|
|
11
|
+
if (size >= publicationRolloverLimit) {
|
|
12
|
+
console.warn('Over pub limit on topic', topicId, size); // TODO: rotate out the earliest received.
|
|
13
|
+
delete bucket[keys[0]];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
7
16
|
bucket[subject] = value;
|
|
8
17
|
}
|
|
9
18
|
function removeBucket(collection, type, topicId, subject) { // Return the value and stop storing it.
|
|
@@ -20,7 +29,7 @@ const PUBLISH_TIMEOUT = 24 * 60 * 60e3; // Delete after 24 hours.
|
|
|
20
29
|
const timeouts = {pub: {}, sub: {}};
|
|
21
30
|
function expire(type, topicId, subject, remover, timeout) { // Cancellably schedule remover() to fire at timeout.
|
|
22
31
|
if (!timeout) return;
|
|
23
|
-
setBucket(timeouts, type, topicId, subject, setTimeout(remover, timeout));
|
|
32
|
+
setBucket(timeouts, type, topicId, subject, setTimeout(() => { remover(); cancel(type, topicId, subject); }, timeout));
|
|
24
33
|
}
|
|
25
34
|
function cancel(type, topicId, subject) { // Cancel a sheduled expiration.
|
|
26
35
|
clearTimeout(removeBucket(timeouts, type, topicId, subject));
|
|
@@ -46,11 +55,19 @@ function normalizeTopic({name, region, owner, write = 'open'} = {}) {
|
|
|
46
55
|
function deriveTopicId(topic) {
|
|
47
56
|
return JSON.stringify(normalizeTopic(topic)); // No need to hash in this implementation.
|
|
48
57
|
}
|
|
58
|
+
function delay(ms = 0) {
|
|
59
|
+
return ms && new Promise(resolve => setTimeout(resolve, ms));
|
|
60
|
+
}
|
|
49
61
|
|
|
50
62
|
let invoke;
|
|
51
63
|
export function setReceiver(receiver) {
|
|
52
64
|
invoke = receiver;
|
|
53
65
|
}
|
|
66
|
+
const throttleMS = 30; // Just to yield to other stuff.
|
|
67
|
+
async function pauseInvoke(...rest) {
|
|
68
|
+
invoke(...rest);
|
|
69
|
+
await new Promise(resolve => setTimeout(resolve, throttleMS));
|
|
70
|
+
}
|
|
54
71
|
|
|
55
72
|
export function subscribe(topicName, nodeTag, {since = 'all'}) {
|
|
56
73
|
// Axona allows multiple handlers on the same topic, but we don't use that in civildefense, and do not implement it here.
|
|
@@ -59,25 +76,27 @@ export function subscribe(topicName, nodeTag, {since = 'all'}) {
|
|
|
59
76
|
cancel('sub', topicId, id);
|
|
60
77
|
expire('sub', topicId, id, () => deleteSub(topicId, id), SUBSCRIPTION_TIMEOUT);
|
|
61
78
|
setBucket(data, 'sub', topicId, nodeTag, id);
|
|
62
|
-
if (since)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
if (since) { // invoke handler on any sticky data, but only after we have told client the subscription id.
|
|
80
|
+
setTimeout(async () => {
|
|
81
|
+
let lastEnvelope = null, lastTime = 0;
|
|
82
|
+
for (const envelope of getDataValues('pub', topicId)) {
|
|
83
|
+
switch (since) {
|
|
84
|
+
case 'all':
|
|
85
|
+
await pauseInvoke(nodeTag, id, envelope);
|
|
86
|
+
break;
|
|
87
|
+
case 'latest':
|
|
88
|
+
if (envelope.ts > lastTime) {
|
|
89
|
+
lastTime = envelope.ts;
|
|
90
|
+
lastEnvelope = envelope;
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
default: // Must be a timestamp
|
|
94
|
+
if (envelope.ts === since) await pauseInvoke(nodeTag, id, envelope);
|
|
73
95
|
}
|
|
74
|
-
break;
|
|
75
|
-
default: // Must be a timestamp
|
|
76
|
-
if (envelope.ts === since) invoke(nodeTag, id, envelope);
|
|
77
96
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
97
|
+
if (lastEnvelope) invoke(nodeTag, id, lastEnvelope);
|
|
98
|
+
}, 100);
|
|
99
|
+
}
|
|
81
100
|
return {topicName, topicId, id};
|
|
82
101
|
}
|
|
83
102
|
|
|
@@ -92,7 +111,10 @@ export function deleteSubscriber(nodeTag) {
|
|
|
92
111
|
for (const topicId in data.sub) {
|
|
93
112
|
const keySubs = data.sub[topicId];
|
|
94
113
|
for (const [subject, value] of Object.entries(keySubs)) {
|
|
95
|
-
if (nodeTag === subject)
|
|
114
|
+
if (nodeTag === subject) {
|
|
115
|
+
deleteSub(topicId, subject, keySubs);
|
|
116
|
+
cancel('sub', topicId, subject);
|
|
117
|
+
}
|
|
96
118
|
}
|
|
97
119
|
}
|
|
98
120
|
}
|
|
@@ -106,19 +128,19 @@ export async function publish(topic, message, {signWith}) {
|
|
|
106
128
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload));
|
|
107
129
|
const msgId = toHex(new Uint8Array(hash));
|
|
108
130
|
const envelope = {msgId, topic, ts: Date.now(), message, signerPubkey};
|
|
109
|
-
for (const [nodeTag, id] of getDataEntries('sub', topicId))
|
|
131
|
+
for (const [nodeTag, id] of getDataEntries('sub', topicId)) await pauseInvoke(nodeTag, id, envelope);
|
|
110
132
|
setBucket(data, 'pub', topicId, msgId, envelope);
|
|
111
133
|
expire('pub', topicId, msgId, () => removeBucket(data, 'pub', topicId, msgId), PUBLISH_TIMEOUT);
|
|
112
134
|
return msgId;
|
|
113
135
|
}
|
|
114
136
|
|
|
115
|
-
export function unpublish(topic, msgId, {signWith}) {
|
|
137
|
+
export async function unpublish(topic, msgId, {signWith}) {
|
|
116
138
|
const topicId = deriveTopicId(topic);
|
|
117
139
|
cancel('pub', topicId, msgId);
|
|
118
140
|
const envelope = removeBucket(data, 'pub', topicId, msgId);
|
|
119
141
|
if (!envelope) return {ok: false}; // we didn't have it.
|
|
120
142
|
envelope.deleted = true;
|
|
121
143
|
envelope.message = null;
|
|
122
|
-
for (const [nodeTag, id] of getDataEntries('sub', topicId))
|
|
144
|
+
for (const [nodeTag, id] of getDataEntries('sub', topicId)) await pauseInvoke(nodeTag, id, envelope);
|
|
123
145
|
return {ok: true};
|
|
124
146
|
}
|
package/public/javascripts/s2.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { s2, s1, r1 } from 's2js';
|
|
2
2
|
const { cellid, LatLng, Point, Cell, Cap, RegionCoverer } = s2;
|
|
3
3
|
import { cellHex } from './versions.js';
|
|
4
|
+
import { geoCellCenter/*, geoCellId*/ } from '@axona/protocol';
|
|
4
5
|
const { BigInt } = globalThis;
|
|
5
6
|
|
|
6
7
|
// s2 defines non-overlapping cells that completely cover the globe, at several different levels of cell-size.
|
|
@@ -12,43 +13,59 @@ const { BigInt } = globalThis;
|
|
|
12
13
|
// Meanwhile as the user changes the area being shown, we subscribe to whatever cells we need in order to cover the display
|
|
13
14
|
// area without overlapping cells.
|
|
14
15
|
|
|
15
|
-
export const MIN_LEVEL = 3; //
|
|
16
|
+
export const MIN_LEVEL = 3; // The largest cell that is still smaller than an Axona region.
|
|
16
17
|
const MAX_S2_LEVEL = 30; // The leaf level that Cell.fromPoint operates at.
|
|
17
18
|
const MAX_MAP_LEVEL = 17; // The max level that findCoverCellsByCenterAndRadius will use on our maps.
|
|
18
19
|
|
|
19
20
|
const EARTH_RADIUS_METERS = 6371e3;
|
|
20
21
|
|
|
21
22
|
export function getPointInCell(cellId) { // answer [lat, lng] in degrees from a BigInt
|
|
23
|
+
// const {lat, lng} = geoCellCenter(cellid);
|
|
24
|
+
// return [lat, lng];
|
|
22
25
|
// CAUTION: This is intended for s2 level 3 or finer, and won't always work symmetrically for our top-level regions.
|
|
23
26
|
// e.g. getContainingCells(...getPointInCell( cell for 0x47 )][0] is 0x46!
|
|
24
27
|
let center = s2.cellid.latLng(cellId);
|
|
25
28
|
return [s1.angle.degrees(center.lat), s1.angle.degrees(center.lng)];
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
function getCellSubdivision(cell) {
|
|
31
|
+
function getCellSubdivision(cell) { // Answer four children off BigInt cell, as BigInt.
|
|
29
32
|
return cellid.children(cell);
|
|
30
33
|
}
|
|
31
|
-
function
|
|
34
|
+
export function getSubdivision(hexString) { // Same as getCellSubDivision, but accepting and returning hex strings.
|
|
35
|
+
return getCellSubdivision(BigInt('0x' + hexString)).map(childCell => cellHex(childCell));
|
|
36
|
+
}
|
|
37
|
+
function getCellLevel(cell) { // Answer level of BigInt cell, as number.
|
|
32
38
|
return cellid.level(cell);
|
|
33
39
|
}
|
|
34
|
-
function
|
|
40
|
+
function getCellFace(cell) { // Answer top level face id of BigInt cell, as integer 0 through 5.
|
|
35
41
|
return cellid.face(cell);
|
|
36
42
|
}
|
|
37
|
-
export function
|
|
38
|
-
return
|
|
43
|
+
export function cellContains(putativeOuter, putativeInner) { // Answer true IFF putativeOuter BigInt cell contains putativeInner
|
|
44
|
+
return cellid.contains(putativeOuter, putativeInner);
|
|
45
|
+
}
|
|
46
|
+
export function pointFromLatLng(lat, lng) { // Answer an s2 Point from lat/lng in degrees.
|
|
47
|
+
const latLng = LatLng.fromDegrees(lat, lng);
|
|
48
|
+
return Point.fromLatLng(latLng);
|
|
49
|
+
}
|
|
50
|
+
export function cellFromCellID(bigint) {
|
|
51
|
+
return Cell.fromCellID(bigint);
|
|
52
|
+
}
|
|
53
|
+
export function getSmallestCellId(lat, lng, level = MAX_MAP_LEVEL) { // Answer smallest BigInt cellid containing latitude/longitude in degrees, at integer level.
|
|
54
|
+
const userPt = pointFromLatLng(lat, lng);
|
|
55
|
+
const userLocCellId = Cell.fromPoint(userPt).id; // This is at MAX_S2_LEVEL
|
|
56
|
+
return cellid.parent(userLocCellId, level);
|
|
39
57
|
}
|
|
40
58
|
|
|
41
|
-
// Return a list of the cell ids that contain the point
|
|
59
|
+
// Return a list of the cell ids that contain the point, from region to MAX_S2_LEVEL
|
|
60
|
+
// Note that the first of the cells (the region
|
|
42
61
|
export function getContainingCells(lat, lng) {
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
for (let level = 0; level <= MAX_S2_LEVEL; level++) { // This would be more efficient going backwards using immediateParent, but who cares.
|
|
49
|
-
cells[level] = cellid.parent(userLocCellId, level);
|
|
62
|
+
let id = getSmallestCellId(lat, lng);
|
|
63
|
+
const level = MAX_MAP_LEVEL, cells = Array(MAX_MAP_LEVEL + 1 - MIN_LEVEL);
|
|
64
|
+
for (let index = level - MIN_LEVEL; index >= 0; index--) {
|
|
65
|
+
cells[index] = id;
|
|
66
|
+
id = cellid.immediateParent(id);
|
|
50
67
|
}
|
|
51
|
-
return cells
|
|
68
|
+
return cells;
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, full = false,
|
|
@@ -58,8 +75,8 @@ export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, fu
|
|
|
58
75
|
|
|
59
76
|
// 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
77
|
// but misses cells in some circumstances, so be wary about rewriting this.
|
|
61
|
-
const lo =
|
|
62
|
-
const hi =
|
|
78
|
+
const lo = LatLng.fromDegrees(minLat, minLng);
|
|
79
|
+
const hi = LatLng.fromDegrees(maxLat, maxLng);
|
|
63
80
|
|
|
64
81
|
const rect = full ? s2.Rect.fullRect() : new s2.Rect(
|
|
65
82
|
new r1.Interval(lo.lat, hi.lat),
|
|
@@ -69,4 +86,3 @@ export function findCoverCellsByMinMaxLatLng({minLat, maxLat, minLng, maxLng, fu
|
|
|
69
86
|
const coverer = new RegionCoverer({minLevel, maxLevel, maxCells});
|
|
70
87
|
return coverer.covering(rect); // a CellUnion — array-like of bigint cell IDs, already normalized/minimal
|
|
71
88
|
}
|
|
72
|
-
|
|
@@ -77,6 +77,13 @@ const translations = {
|
|
|
77
77
|
['#downloadDefer']: {en: "no, not yet", es: "No, todavía no."},
|
|
78
78
|
['No update at']: {es: "No hay actualizaciones a las"},
|
|
79
79
|
['is available']: {es: "está disponible"},
|
|
80
|
+
['>Zoom in on multiple alerts in this area.']: {es: "Amplía la vista sobre varias alertas en esta zona."},
|
|
81
|
+
|
|
82
|
+
['.firstConversation .teach.correspondent']: {en: "Change how you label this correspondent.", es: "Cambia la forma en que etiquetas a este corresponsal."},
|
|
83
|
+
['.firstPublish .teach.correspondent']: {en: "Change how you label yourself.", es: "Cambia la forma en que te etiquetas."},
|
|
84
|
+
['.teach.attach']: {en: "Attach a file to your reply.", es: "Adjunta un archivo a tu respuesta."},
|
|
85
|
+
['.teach.share']: {en: "Share this alert through other programs.", es: "Comparte esta alerta a través de otros programas."},
|
|
86
|
+
['.teach.changeHashtag']: {en: "Delete your alert or change its topic.", es: "Elimina tu alerta o cambia su tema."},
|
|
80
87
|
|
|
81
88
|
['#describePrivate1']: {en: "Here are your private labels for", es: "Aquí están sus etiquetas privadas para"},
|
|
82
89
|
['#describePrivate2']: {en: "to help you recognize posts from them:", es: "Para ayudarte a reconocer publicaciones de ellas:"},
|
|
@@ -114,17 +121,11 @@ const translations = {
|
|
|
114
121
|
[' share a link to this map and topics, with picture of map.']: {es: " comparte un enlace a este mapa y a los temas, junto con una imagen del mapa."},
|
|
115
122
|
['Recenter the map to where you are in the world.']: {es: "Vuelve a centrar el mapa en tu ubicación actual."},
|
|
116
123
|
['Post your reply.']: {es: "Publica tu respuesta."},
|
|
117
|
-
['Attach a file to your reply.']: {es: "Adjunta un archivo a tu respuesta."},
|
|
118
124
|
['Delete your reply.']: {es: "Elimina tu respuesta."},
|
|
119
|
-
['Change the topic or delete your alert.']: {es: "Cambia el tema o elimina tu alerta."},
|
|
120
125
|
['Check to see if a new version of the app is available.']: {es: "Comprueba si hay una nueva versión de la aplicación disponible."},
|
|
121
126
|
['Update to a new version of this app.']: {es: "Actualiza a una nueva versión de esta aplicación."},
|
|
122
|
-
['Share though .']: {es: "Comparte a través de "},
|
|
123
127
|
[' the text and attachments of this reply, with a link to open this alert.']: {es: " el texto y los archivos adjuntos de esta respuesta, junto con un enlace para abrir esta alerta."},
|
|
124
|
-
['Share through ']: {es: "Comparte un enlace a través de "},
|
|
125
128
|
[' a link to open this alert.']: {es: " para abrir esta alerta."},
|
|
126
|
-
['Control how others see me.']: {es: "Controla cómo me ven los demás."},
|
|
127
|
-
['Control how this person is labeled on my device.']: {es: "Controla cómo se etiqueta a esta persona en mi dispositivo."},
|
|
128
129
|
['Choose the label you want to appear for alerts and replies by this user.']: {es: "Selecciona la etiqueta que quieras que aparezca para las alertas y respuestas de este usuario."},
|
|
129
130
|
["Capture this unique text so that even if the user changes what they share, you'll still see this text for alerts and replies by this user."]: {es: "Captura este texto único para que, aunque el usuario cambie lo que comparte, sigas viendo este texto en las alertas y respuestas de dicho usuario."},
|
|
130
131
|
["Capture this name they are currently sharing, so that even if the user changes what they share, you'll still see this text for alerts and replies by this user."]: {es: "Guarda el nombre que están compartiendo actualmente, de modo que, aunque el usuario cambie lo que comparte, sigas viendo este texto en las alertas y respuestas de dicho usuario."},
|
|
@@ -132,7 +133,10 @@ const translations = {
|
|
|
132
133
|
["Capture this image they are currently sharing, so that even if the user changes what they share, you'll still see this image for alerts and replies by this user."]: {es: "Guarda esta imagen que están compartiendo actualmente, de modo que, aunque el usuario cambie lo que comparte, sigas viendo esta imagen en las alertas y respuestas de dicho usuario."},
|
|
133
134
|
['Choose the picture you want to appear for alerts and replies by this user.']: {es: "Elige la imagen que quieras que aparezca para las alertas y respuestas de este usuario."},
|
|
134
135
|
['Enable local ']: {es: "Habilita notificaciones locales de "},
|
|
135
|
-
[' notifcations for map alerts, without going through any servers. Requires that the app be running.']: {es: " para alertas del mapa, sin pasar por ningún servidor. Requiere que la aplicación esté en ejecución."},
|
|
136
|
+
[' notifcations for map alerts, without going through any servers. Requires that the app be running.']: {es: " para alertas del mapa, sin pasar por ningún servidor. Requiere que la aplicación esté en ejecución."},
|
|
137
|
+
['There are already at least ']: {es: "Ya hay al menos "},
|
|
138
|
+
[' alerts of type "']: {es: ' alertas de tipo "'},
|
|
139
|
+
['" in the colored region. Zooming in to get a better look.']: {es: '" en la región coloreada. Ampliando la imagen para verla mejor.'},
|
|
136
140
|
|
|
137
141
|
|
|
138
142
|
['Too many posts. (5 allowed every 5 minutes.) Removing oldest from this period.']: {es: "Demasiadas publicaciones. (Se permiten 5 cada 5 minutos). Eliminando las más antiguas de este período."},
|
|
@@ -25,10 +25,16 @@ export function cellHex(cellid) { // Convert cellid BigInt to properly padded he
|
|
|
25
25
|
export function alertTopic(cellid, tag) { // Return topic name for public info about specified tag in cellid.
|
|
26
26
|
return `civildefense.io:${dataVersion}:${cellHex(cellid)}:${canonicalTag(tag)}`;
|
|
27
27
|
}
|
|
28
|
-
export function
|
|
29
|
-
|
|
28
|
+
export function topicTag(topicName) { // Return the hashtag.
|
|
29
|
+
return topicName.slice(topicName.lastIndexOf(':') + 1); // Currently, colon is not allowed in hashtags.
|
|
30
|
+
}
|
|
31
|
+
export function topicCellHex(topicName) { // Return the hex string cell that is baked in to the topic.
|
|
32
|
+
// dataVersion cannot contain a colon.
|
|
30
33
|
return topicName.match(/civildefense.io:[^:]+:([a-z0-9]+):/)[1];
|
|
31
34
|
}
|
|
35
|
+
export function topicCell(topicName) { // Return the BigInt cell that is baked in to the topic.
|
|
36
|
+
return BigInt('0x' + topicCellHex(topicName));
|
|
37
|
+
}
|
|
32
38
|
export function topicRegion(topicName) { // Return the region that is baked in to the topic.
|
|
33
|
-
return '0x' +
|
|
39
|
+
return '0x' + topicCellHex(topicName).slice(0, 2);
|
|
34
40
|
}
|
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.28';
|
|
4
4
|
|
|
5
5
|
const cacheList = [ // The files we need.
|
|
6
6
|
"/",
|
|
@@ -124,10 +124,13 @@ async function cacheFirst({request, event}) {
|
|
|
124
124
|
try {
|
|
125
125
|
const responseFromNetwork = await fetch(request);
|
|
126
126
|
if (request.method !== 'GET' || request.cache === 'no-store') return responseFromNetwork; // Cache shouldn't allow anyway.
|
|
127
|
+
const mime = responseFromNetwork.headers.get("Content-Type");
|
|
128
|
+
if (/^(audio|video)\//.test(mime)) return responseFromNetwork; // Do not cache streamable media.
|
|
127
129
|
// Put clone of response in cache (so that original can be returned.
|
|
128
130
|
// Tell event to keep worker open while we put it, even though we return response immediately.
|
|
129
131
|
const cache = await caches.open(serviceVersion);
|
|
130
|
-
event.waitUntil(cache.put(request, responseFromNetwork.clone())
|
|
132
|
+
event.waitUntil(cache.put(request, responseFromNetwork.clone())
|
|
133
|
+
.catch(error => console.error(request.url, mime, error)));
|
|
131
134
|
return responseFromNetwork;
|
|
132
135
|
} catch (error) {
|
|
133
136
|
console.error(request.url, error);
|