@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.
@@ -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
+ }
@@ -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 = 2; // Corresponds to the top level Axona regions.
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
- let {lat, lng} = s2.cellid.latLng(cellId);
21
- const degrees = 180 / Math.PI;
22
- lat *= degrees;
23
- lng *= degrees;
24
- return [lat, lng];
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
- // 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
- }
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
- // 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));
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
- // 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
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 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);
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 = "Installing..."; // In case there is some delay, tell the user what we're trying to do. Will be cleared with reload.
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 = new URL(location.href);
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
- // 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, '') || string;
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
  }
@@ -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.9';
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
- // for (const file of cacheList) cache.add(new Request(file, {cache: 'no-store'})).catch(error => console.error(file + ' ' + error.message)); // dev testing for changed file tree
170
- await cache.addAll(cacheList.map(name => new Request(name, {cache: 'no-store'}))); // Might not be necessary to specify no-store, but if any browsers insist on their own caching...
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.1/static-html.js/+esm",
178
- "https://esm.run/npm/lit@3.3.1/decorators.js/+esm",
179
- "https://esm.run/npm/lit@3.3.1/directives/style-map.js/+esm",
180
- "https://esm.run/npm/lit@3.3.1/directives/class-map.js/+esm",
181
- "https://esm.run/npm/lit@3.3.1/directives/when.js/+esm",
182
- "https://esm.run/npm/lit@3.3.1/directives/live.js/+esm",
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
- bottom: calc(var(--textFieldHeight) + 2px);
205
- left: 0;
206
- max-height: 50dvh; /* leaving room for portrait keyboard. landscape keyboard has no room at all */
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
- z-index: 9999;
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: var(--textFieldHeight);
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
- text-wrap: nowrap;
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;