@yz-social/civildefense.io 4.5.6 → 4.5.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yz-social/civildefense.io",
3
3
  "description": "Browser app to safely share live sightings on a map.",
4
- "version": "4.5.6",
4
+ "version": "4.5.8",
5
5
  "keywords": [
6
6
  "map",
7
7
  "browser",
@@ -17,7 +17,7 @@
17
17
  ".": "./index.js"
18
18
  },
19
19
  "dependencies": {
20
- "@axona/protocol": "github:axona-net/axona-protocol#5bf6f888cbbb5a58b9700c11582a4b6ca4c3141e",
20
+ "@axona/protocol": "github:axona-net/axona-protocol#semver:^4.29.0",
21
21
  "cors": "^2.8.6",
22
22
  "express": "^4.22.1",
23
23
  "leaflet": "^1.9.4",
@@ -132,12 +132,21 @@
132
132
  <p>Axona is a modern descendant of the old <a href="https://en.wikipedia.org/wiki/Peer-to-peer" target="yz.other" rel="noopener">peer-to-peer networks</a> such as <a href="https://en.wikipedia.org/wiki/Napster">Napster</a>. It has a unique self-healing routing mechanism that uses neuron learning techniques to find the best way to distribute alerts. It is analogous to <a href="https://en.wikipedia.org/wiki/Tor_(network)" target="yz.other" rel="noopener">TOR</a> (The Onion Router), but designed from the ground up to run in mobile and desktop browsers.</p>
133
133
  <p>We are also developing a comprehensive quantum-ready cryptography system that will add private-groups for Civil Defense and any software on the network. We believe there are many applications where developers, users, and the public all benefit from open software and secure, decentralized networks — without app stores, servers, or data centers. Beyond Civil Defense, we are also developing large-scale <a href="https://en.wikipedia.org/wiki/Distributed_computing" target="yz.other" rel="noopener">multiuser collaboration</a> and <a href="https://poverty-action.org/inclusive-instant-payment-systems-evidence-based-approach-design-impact" target="yz.other" rel="noopener">Inclusive Instant Payment Systems</a>.</p>
134
134
 
135
- </main>
135
+
136
+ <div class="centered">
137
+ <img src="serving-you.jpg" alt="Civil Defense Week poster - Serving you in time of emergency">
138
+ </div>
139
+
140
+ <div class="centered">
141
+ <img src="prevail.jpg" alt="Cold-War Civil Defense poster - Prepare, Protect, Prevail">
142
+ </div>
136
143
 
137
144
  <div class="centered">
138
145
  <img src="nazis.png" alt="WWII-style poster exhorting the public to use Civil Defense in case of Nazi attack.">
139
146
  </div>
140
147
 
148
+ </main>
149
+
141
150
  <!-- ── FOOTER ─────────────────────────────────────────────────── -->
142
151
  <footer>
143
152
  <p><a href="/?lang=en" target="civildefense">civildefense.io</a> &mdash; Safe and free crowdmapping for vulnerable populations.<br>
@@ -117,12 +117,21 @@
117
117
  <p>Axona es un descendiente moderno de las antiguas <a href="https://en.wikipedia.org/wiki/Peer-to-peer" target="yz.other" rel="noopener">redes entre pares</a> como <a href="https://en.wikipedia.org/wiki/Napster">Napster</a>. Tiene un mecanismo único de enrutamiento autocurativo que utiliza técnicas de aprendizaje neuronal para encontrar la mejor manera de distribuir alertas. Es análogo a <a href="https://en.wikipedia.org/wiki/Tor_(network)" target="yz.other" rel="noopener">TOR</a> (The Onion Router), pero diseñado desde cero para funcionar en navegadores móviles y de escritorio.</p>
118
118
  <p>También estamos desarrollando un sistema integral de criptografía preparado para la era cuántica que añadirá grupos privados para Defensa Civil y cualquier software en la red. Creemos que hay muchas aplicaciones en las que los desarrolladores, los usuarios y el público en general se benefician del software abierto y las redes seguras y descentralizadas, sin tiendas de aplicaciones, servidores ni centros de datos. Más allá de Defensa Civil, también estamos desarrollando <a href="https://en.wikipedia.org/wiki/Distributed_computing" target="yz.other" rel="noopener">colaboración multiusuario a gran escala</a> y <a href="https://poverty-action.org/inclusive-instant-payment-systems-evidence-based-approach-design-impact" target="yz.other" rel="noopener">Sistemas de Pago Instantáneo Inclusivos</a>.</p>
119
119
 
120
- </main>
120
+ <div class="centered">
121
+ <img src="serving-you.jpg" alt="Cartel de la Semana de la Defensa Civil: A su servicio en caso de emergencia">
122
+ </div>
123
+
124
+ <div class="centered">
125
+ <img src="prevail.jpg" alt="Póster de Defensa Civil de la Guerra Fría: Preparar, proteger, prevalecer">
126
+ </div>
121
127
 
122
128
  <div class="centered">
123
129
  <img src="nazis.png" alt="Póster de estilo de la Segunda Guerra Mundial que exhorta al público a usar Defensa Civil en caso de ataque nazi.">
124
130
  </div>
125
131
 
132
+ </main>
133
+
134
+
126
135
  <!-- ── FOOTER ─────────────────────────────────────────────────── -->
127
136
  <footer>
128
137
  <p><a href="/?lang=es" target="civildefense">civildefense.io</a> &mdash; Mapeo colaborativo seguro y gratuito para poblaciones vulnerables.<br>
Binary file
Binary file
@@ -6,7 +6,7 @@ import { networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, cl
6
6
  import { consume } from './display.js';
7
7
  import { Hashtags } from './hashtags.js';
8
8
  import { Agent } from './agent.js';
9
- import { Conversation } from './conversation.js';
9
+ import { Conversation, Reply } from './conversation.js';
10
10
  import { alertTopic } from './versions.js';
11
11
  import { getContainingCells, findCoverCellsByCenterAndPoint } from './s2.js';
12
12
  const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
@@ -72,6 +72,37 @@ export function go({lat = null, lng = null, zoom = null, alert = null}) { // Go
72
72
  }
73
73
  }
74
74
 
75
+ class AlertReply extends Reply {
76
+ async initialize(properties) { // Set properties of this AlertReply.
77
+ // Currently, this waits for any attachment to be fetched. (Conversation.ensure() will wait as long as we need.)
78
+ // This means that the alert will not show up until we have the attachments, and then it shows up all at once with the text,
79
+ // attribution, and attachment. (There's no img or video "loading" period because the attachment comes through as a complete
80
+ // data url.) This is convenient for the formatReply function, because until the chunked attachment is re-assembled, we won't
81
+ // know which kind of player to use, and we won't be able to assign the containing A[href].
82
+ //
83
+ // Alternatively, we could in the future choose to have the attributions and text appear right away, with a "spinner"
84
+ // placeholder that turns into the appropriate A/player when the attachment resolves. To do that, we would need to record here
85
+ // a promise to reassemble the attachment, have formatReply create a findable placeholder to later replace with the A element
86
+ // and contents, and then have ensureContent() arrange in its delayed followup to replace the placeholder when the promise
87
+ // resolves.
88
+ super.initialize(properties);
89
+ const {container, agent, issuedTime, payload} = properties;
90
+ const {file:attachmentTopic} = payload;
91
+ if (attachmentTopic) {
92
+ const contact = await networkPromise;
93
+ // Before pushing data on to replies.
94
+ const {dataURL:file, name, msgIds} = await contact.assembleChunkedDataURL(attachmentTopic);
95
+ Object.assign(payload, {file, name, attachmentTopic, msgIds});
96
+ }
97
+ const element = container.startFader('.alert-commented', issuedTime + ttl - Date.now());
98
+ element.style.display = 'block';
99
+ // Restart the pulse animation by setting animationName to something it isn't.
100
+ element.style.animationName = element.style.animationName === 'pulse2' ? 'pulse' : 'pulse2';
101
+ container.showNotification({agent, issuedTime, body: payload.message || payload.name || payload});
102
+ return this;
103
+ }
104
+ }
105
+
75
106
  let subscriptions = []; // array of stringy keys <mumble>:<cellID>:<hashtag>
76
107
  let subscriptionsRegion;
77
108
  // We do not record exactly where you were looking across sessions, but we do record the containing level 9 cell.
@@ -129,7 +160,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
129
160
  // However, the 'unpublishing' (if any) is invoked first.
130
161
  // To do this, we must hash the eventName ourselves.
131
162
  //console.log('publish', {lat, lng, hashtag, payload, cancel, subject, issuedTime, rest});
132
- if (publishing) { console.log('skiping overlappying publish'); return null; } // do not stack them up.
163
+ if (publishing) { console.log('skiping overlapping publish'); return null; } // do not stack them up.
133
164
  try {
134
165
  publishing = true;
135
166
 
@@ -230,8 +261,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
230
261
  wrapper.initChangeHashtag(popupAttribution);
231
262
  });
232
263
  }
233
- static ensure({tag, topic, ts, issuedTime, ...rest}) { // Add marker at position with appropriate fade if not already present.
234
- const alert = super.ensure({tag, subject: tag, issuedTime, ...rest}); // Does not include topic or ts. fixme: get rid of subject. fixme: why is ts different?
264
+ static async ensure({tag, topic, ts, issuedTime, ...rest}) { // Add marker at position with appropriate fade if not already present.
265
+ const alert = await super.ensure({tag, subject: tag, issuedTime, ...rest}); // Does not include topic or ts. fixme: get rid of subject. fixme: why is ts different?
235
266
  if (!alert) return null;
236
267
  // Regardless of initialize vs update, reset fader.
237
268
  const now = Date.now(),
@@ -312,7 +343,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
312
343
  this.initChangeHashtag(popupElement);
313
344
  for (const correspondent of popupElement.querySelectorAll('.correspondent')) {
314
345
  const tag = correspondent.dataset.tag;
315
- const agent = Agent.ensure({tag, region: this.region});
346
+ const region = P2PWebNetwork.regionCode(this.lat, this.lng);
347
+ const agent = Agent.ensure({tag, region: region});
316
348
  const isAvatar = correspondent.classList.contains('avatar');
317
349
  if (agent.addElement(correspondent, 'mixed', isAvatar ? 'avatar' : 'handle')) {
318
350
  const isMine = Agent.isMine(tag);
@@ -408,28 +440,15 @@ export class Alert extends Conversation { // A wrapper around L.marker
408
440
  }
409
441
 
410
442
  // Each reply is separately published by its author, and only they can modify/unpublish it.
443
+ get itemKind() { // Answer class of reply items.
444
+ return AlertReply;
445
+ }
411
446
  async ensure(data) { // Add or update reply for this marker.
412
447
  data.subject = data.tag; //fixme
413
- const reply = super.ensure(data);
414
- if (reply) { // TODO? Move to AlertReply.initialize()?
415
- const {agent, issuedTime, payload} = data;
416
- const {file} = payload;
417
- if (file) {
418
- data.fileTopic = file;
419
- const contact = await networkPromise;
420
- // Before pushing data on to replies.
421
- const {dataURL, name} = await contact.assembleChunkedDataURL(file);
422
- payload.file = dataURL;
423
- payload.name = name;
424
- }
425
- const element = this.startFader('.alert-commented', issuedTime + ttl - Date.now());
426
- element.style.display = 'block';
427
- // Restart the pulse animation by setting animationName to something it isn't.
428
- element.style.animationName = element.style.animationName === 'pulse2' ? 'pulse' : 'pulse2';
429
- this.showNotification({agent, issuedTime, body: payload.message || payload.name || payload});
430
- }
448
+ const reply = await super.ensure(data);
431
449
  this.needsRedisplay = true;
432
450
  this.ensureContent();
451
+ return reply;
433
452
  }
434
453
  async postReply(event) { // Post a reply to this marker's subject, in response to a text-field change event.
435
454
  resetInactivityTimer();
@@ -437,14 +456,15 @@ export class Alert extends Conversation { // A wrapper around L.marker
437
456
  const button = event.target;
438
457
  const inputElement = button.parentElement;
439
458
  let payload = inputElement.value.trim();
440
- const {subject, hashtag, region} = this;
459
+ const {subject, hashtag, lat, lng} = this;
460
+ const region = P2PWebNetwork.regionCode(lat, lng);
441
461
  const files = inputElement.parentElement.querySelector('input[type="file"]').files;
442
462
  if (!payload && !files.length) return;
443
463
  inputElement.value = '';
444
464
  inputElement.querySelector('md-filled-icon-button').toggleAttribute('disabled', true);
445
465
  const contact = await networkPromise;
446
466
  if (files.length) {
447
- const {topic:file} = await contact.chunkifyBlob({blob: files[0], region});
467
+ const {topic:file, msgIds} = await contact.chunkifyBlob({blob: files[0], region});
448
468
  payload = {message: payload, file};
449
469
  }
450
470
  await contact.publish({eventName: subject, region, payload}); // Publish the new reply.
@@ -452,8 +472,22 @@ export class Alert extends Conversation { // A wrapper around L.marker
452
472
  }
453
473
  deleteReply(replyElement) {
454
474
  resetInactivityTimer();
455
- const {region} = this;
456
- networkPromise.then(async contact => contact.publish({eventName: this.subject, region, killTag: replyElement.dataset.subject, payload: null}));
475
+ const {lat, lng} = this;
476
+ const region = P2PWebNetwork.regionCode(lat, lng);
477
+ const killTag = replyElement.dataset.subject;
478
+ networkPromise.then(async contact => {
479
+ // We won't be here unless we are the signer.
480
+ await contact.publish({eventName: this.subject, region, killTag, payload: null});
481
+ // IFF there's an attachment AND we're given msgIds by receiveChunkedBytes, then delete the attachment.
482
+ const reply = this.getItem(killTag);
483
+ const {attachmentTopic, msgIds = []} = reply?.payload || {};
484
+ if (msgIds.length) {
485
+ const {name, owner, region} = attachmentTopic;
486
+ for (const killTag of msgIds) {
487
+ await contact.publish({eventName: name, region, owner, killTag, payload: null});
488
+ }
489
+ }
490
+ });
457
491
  }
458
492
  showNotification({issuedTime = this.issuedTime, body = '', agent = this.agent, alert = this.subject, lat = this.lat, lng = this.lng, hashtag = this.hashtag}) {
459
493
  // Give OS notification that comes back to here, unless act is us.
@@ -469,18 +503,21 @@ export class Alert extends Conversation { // A wrapper around L.marker
469
503
  registration.showNotification(hashtag, options);
470
504
  });
471
505
  }
506
+ // Each reply element is a DIV.reply with data-subject and data-text attributes that are used in sharing.
507
+ // It contains an attribution header with controls, zero or one attachments, and then the message text.
508
+ // If present the attachment will be an A element with download attribute, surrounding either an IMG, A/V player, or an attachment icon followed by the file name.
472
509
  formatReplies() { // Answer HTML for the replies and input box.
473
510
  const { items, agent, originalPosting } = this;
474
511
  const formatReply = ({subject, payload, ...rest}) => {
475
- const {message = payload, file, name} = payload;
512
+ const {message = payload, file, name} = payload || {}; // Message text converts recognized urls to A/V players or links.
476
513
  let text = message
477
514
  .replace(/https?:\/\/\S+\.(mp3|aac|ogg|oga|opus|m4a|m3u8|m3u|mpu|mpd)$/ig, url => `<audio controls src="${url}"></audio>`) // show audio urls as players
478
515
  .replace(/https?:\/\/\S+\.(mp4|mov|webm)$/ig, url => `<video controls src="${url}"></video>`) // show video urls as players
479
516
  .replace(/(?<!")https?:\/\/\S+/g, url => `<a href="${url}" target="yz.sidebar">${url}</a>`); // show urls as links
480
517
  let attachment = '';
481
- if (file?.startsWith('data:image')) attachment = `<a href="${file}" download="${name}"><img class="attachment" src="${file}"></img></a>`;
482
- else if (file?.startsWith('data:audio')) attachment = `<a href="${file}" download="${name}"><audio controls class="attachment" src="${file}"></audio></a>`;
483
- else if (file?.startsWith('data:video')) attachment = `<a href="${file}" download="${name}"><video controls class="attachment" src="${file}"></video></a>`;
518
+ if (file?.startsWith?.('data:image')) attachment = `<a href="${file}" download="${name}"><img class="attachment" src="${file}"></img></a>`;
519
+ else if (file?.startsWith?.('data:audio')) attachment = `<a href="${file}" download="${name}"><audio controls class="attachment" src="${file}"></audio></a>`;
520
+ else if (file?.startsWith?.('data:video')) attachment = `<a href="${file}" download="${name}"><video controls class="attachment" src="${file}"></video></a>`;
484
521
  else if (file) attachment = `
485
522
  <div class="attachment file">
486
523
  <a href="${file}" download="${name}">
@@ -29,12 +29,12 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
29
29
  return null;
30
30
  }
31
31
 
32
- static ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
32
+ static async ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
33
33
  const {tag, payload, ...rest} = data;
34
34
  let item = container.getItem(tag);
35
35
  if (!payload) return item && container.removeItem(tag)?.destroy();
36
- if (item) item = item.update(data);
37
- else item = new kind().initialize(data);
36
+ if (item) item = await item.update(data);
37
+ else item = await (new kind().initialize({...data, container}));
38
38
 
39
39
  if (!item) return container.removeItem(tag)?.destroy();
40
40
  return container.setItem(tag, item);
@@ -123,7 +123,7 @@ export function openAbout(event) {
123
123
  openDisplay('aboutContainer', event);
124
124
  noteNotificationPermission(window.Notification?.permission);
125
125
  }
126
- clickTip('#aboutButton', Int`Information about this app, and options to change how you appear to others.`, event => { // open about
126
+ clickTip('#aboutButton', Int`Information about this app, and options to change notifications or how you appear to others.`, event => { // open about
127
127
  Alert.closePopup();
128
128
  openAbout(event);
129
129
  });
@@ -43,18 +43,17 @@ export function updateLocation(lat, lng, zoom, positionLabel) { // initMap if ne
43
43
  if (!map) {
44
44
  initMap(lat, lng, zoom, positionLabel);
45
45
 
46
- const params = new URL(location).searchParams;
46
+ const url = new URL(location);
47
+ const params = url.searchParams;
47
48
  const tags = params.get('tags');
48
49
  const tagsArray = tags?.split(',') || [];
49
50
  tagsArray.forEach(tag => Hashtags.add(decodeURIComponent(tag)));
50
51
  Hashtags.onchange({resetSubscriptions: false}); // Too early to subscribe, but will be done during initialization.
51
52
  go({lat: params.get('lat'), lng: params.get('lng'), zoom: params.get('z'), alert: params.get('alert')});
52
53
  // We don't need the query parameters now. Get rid of them. They're annoying.
53
- const copy = new URL(location);
54
- const dht = copy.searchParams.get('dht');
55
- if (copy.searchParams.size > 0) {
56
- copy.search = '';
57
- history.replaceState(null, '', copy);
54
+ if (params.size > 0) {
55
+ ['tags', 'lat', 'lng', 'z', 'alert'].forEach(key => params.delete(key));
56
+ history.replaceState(null, '', url);
58
57
  }
59
58
 
60
59
  return;
@@ -2,6 +2,13 @@ import { v4 as uuidv4 } from 'uuid';
2
2
  import { createNodeIdentity, createAuthorIdentity, geoCellId, geoCellCenter, WIRE_VERSION, KERNEL_VERSION } from '@axona/protocol';
3
3
  import { stringToBytes, bytesToString, publishChunkedBytes, receiveChunkedBytes } from '@axona/protocol/std';
4
4
  import { connect } from '@axona/protocol/connect.js';
5
+ globalThis.RTCPeerConnection ||= await import('node-datachannel/polyfill').then(ndc => ndc.RTCPeerConnection); // To support @axona/protocol < 4.26
6
+ if (!Uint8Array.prototype.toBase64) { // NodeJS < 24
7
+ Object.defineProperty(Uint8Array.prototype, 'toBase64', {
8
+ value: function toBase64() { return Buffer.from(this.buffer, this.byteOffset, this.byteLength).toString('base64'); },
9
+ writable: true, configurable: true,
10
+ });
11
+ }
5
12
  const { BigInt, URL, File, pica } = globalThis;
6
13
 
7
14
  /* Example:
@@ -20,7 +27,9 @@ export class P2PWebNetwork {
20
27
  static sessionRegion = sessionRegionPromise;
21
28
  static async create({infoLogger = console.log, debugLogger,
22
29
  region = this.sessionRegion,
23
- bridgeUrl = globalThis.process?.env.BRIDGE_URL || 'wss://bridge.axona.net',
30
+ bridgeUrl = (globalThis.location && new URL(globalThis.location).searchParams.get('bridge')) ||
31
+ globalThis.process?.env.BRIDGE_URL ||
32
+ 'wss://bridge.axona.net',
24
33
  } = {}) {
25
34
  // Promise a ready-to-use network peer.
26
35
  region = await region;
@@ -35,6 +44,14 @@ export class P2PWebNetwork {
35
44
  Object.assign(network, {infoLogger, debugLogger, disconnector: disconnect, transport, nodeIdentity, peer});
36
45
  network.resetStatePromises();
37
46
  network.info(`Created network node for kernel ${this.kernelVersion} region 0x${this.regionCode(region.lat, region.lng).toString(16)}.`);
47
+ peer.onError(error => {
48
+ network.info(`error: ${error.message || error}`);
49
+ throw error;
50
+ });
51
+ //peer.onLog('debug', (...rest) => network.debug('DEBUG', ...rest));
52
+ //peer.onLog('info', (...rest) => network.debug('INFO', ...rest));
53
+ peer.onLog('warn', (...rest) => network.info('WARNING', ...rest));
54
+ peer.onLog('error', (...rest) => network.info('ERROR', ...rest));
38
55
  const { peers, ms } = status;
39
56
  network.info(`Connected ${peers} connections through ${bridgeUrl} in ${ms.toLocaleString()} ms.`);
40
57
  network.attached(network);
@@ -106,7 +106,7 @@ const translations = {
106
106
  ['Change the image shown for you in the conversation for your alerts and replies.']: {es: "Cambia la imagen que se muestra en la conversación para tus alertas y respuestas."},
107
107
  ['Toggle whether alerts for this topic shown on the map. Separately, a radio button is shown when selected and sets this as the initial topic of the next alert you make, while an x button is shown when deselected and removes the topic.']: {es: "Alterna la visualización en el mapa de las alertas relacionadas con este tema. Por otro lado, al seleccionar el tema aparece un botón de opción que lo establece como el tema inicial de la próxima alerta que crees; al deseleccionarlo, aparece un botón con una «x» que elimina dicho tema."},
108
108
  ['Add a new topic for which the map should show any alerts.']: {es: "Añada un nuevo tema para el cual el mapa deba mostrar alertas."},
109
- ['Information about this app, and options to change how you appear to others.']: {es: "Información sobre esta aplicación y opciones para cambiar cómo apareces ante los demás."},
109
+ ['Information about this app, and options to change notifications or how you appear to others.']: {es: "Información sobre esta aplicación y opciones para modificar las notificaciones o cómo apareces ante los demás."},
110
110
  ['Wipe from ']: {es: "Elimina de "},
111
111
  [' all personal data and source files for this app.']: {es: " todos los datos personales y archivos fuente de esta aplicación."},
112
112
  ['Display QR code that can be scanned by a friend to show the same map on their device.']: {es: "Muestra un código QR que un amigo puede escanear para ver el mismo mapa en su dispositivo."},
@@ -0,0 +1,145 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Material Web Autocomplete Combo Box</title>
6
+ <script type="importmap">
7
+ {
8
+ "imports": {
9
+ "uuid": "https://unpkg.com/uuid@13.0.0/dist/index.js",
10
+ "@material/web/": "https://esm.run/@material/web/",
11
+ "s2js": "./s2js/s2js.esm.js",
12
+ "bigfloat": "./bigfloat/esm/index.js",
13
+ "leaflet": "./leaflet/leaflet-src.esm.js",
14
+ "minidenticons": "./minidenticons/minidenticons.min.js",
15
+ "@axona/protocol": "./axona-protocol/src/index.js",
16
+ "@axona/protocol/std": "./axona-protocol/std/index.js",
17
+ "@axona/protocol/connect.js": "./axona-protocol/src/connect.js"
18
+ }
19
+ }
20
+ </script>
21
+ <script type="module">
22
+ import '@material/web/all.js';
23
+ import {styles as typescaleStyles} from '@material/web/typography/md-typescale-styles.js';
24
+ document.adoptedStyleSheets.push(typescaleStyles.styleSheet);
25
+ </script>
26
+ <!-- Load Material Web Components via CDN -->
27
+ <!-- <script type="module"> -->
28
+ <!-- import '@material/web/textfield/filled-text-field.js'; -->
29
+ <!-- import '@material/web/menu/menu.js'; -->
30
+ <!-- import '@material/web/menu/menu-item.js'; -->
31
+ <!-- </script> -->
32
+ <style>
33
+ .combo-box-container {
34
+ position: relative;
35
+ display: inline-block;
36
+ width: 300px;
37
+ margin: 40px;
38
+ }
39
+ md-filled-text-field {
40
+ width: 100%;
41
+ }
42
+ md-menu {
43
+ /* Match the width of the textfield anchor */
44
+ --md-menu-container-width: 300px;
45
+ }
46
+ </style>
47
+ </head>
48
+ <body>
49
+
50
+ <div class="combo-box-container">
51
+ <!-- The editable text input acting as the anchor -->
52
+ <md-filled-text-field
53
+ id="combo-input"
54
+ label="Choose a fruit"
55
+ placeholder="Type to search..."
56
+ autocomplete="off">
57
+ </md-filled-text-field>
58
+
59
+ <!-- The dropdown menu containing autocomplete suggestions -->
60
+ <md-menu id="combo-menu" anchor="combo-input" stay-open-on-outside-click>
61
+ <!-- Populated dynamically via JavaScript -->
62
+ </md-menu>
63
+ </div>
64
+
65
+ <script>
66
+ const input = document.getElementById('combo-input');
67
+ const menu = document.getElementById('combo-menu');
68
+
69
+ // Dataset for autocomplete
70
+ const itemsList = [
71
+ 'Apple', 'Banana', 'Blueberry', 'Cherry', 'Grape',
72
+ 'Lemon', 'Mango', 'Orange', 'Peach', 'Strawberry'
73
+ ];
74
+
75
+ // Build and filter menu items based on input value
76
+ function updateMenu(filterText = '') {
77
+ const normalizedFilter = filterText.toLowerCase().trim();
78
+
79
+ // Filter list items
80
+ const filtered = itemsList.filter(item =>
81
+ item.toLowerCase().includes(normalizedFilter)
82
+ );
83
+
84
+ // Clear existing menu items
85
+ menu.innerHTML = '';
86
+
87
+ if (filtered.length === 0) {
88
+ // Show a disabled helper item if no match found
89
+ const noResult = document.createElement('md-menu-item');
90
+ noResult.disabled = true;
91
+ //noResult.headline = 'No matches found';
92
+ noResult.textContent = 'No matches found';
93
+ menu.appendChild(noResult);
94
+ return;
95
+ }
96
+
97
+ // Append matched options
98
+ filtered.forEach(item => {
99
+ const menuItem = document.createElement('md-menu-item');
100
+ //menuItem.headline = item;
101
+ menuItem.textContent = item;
102
+ // Store raw value on the element for extraction during selection
103
+ menuItem.dataset.value = item;
104
+ menu.appendChild(menuItem);
105
+ });
106
+ }
107
+
108
+ // Filter list and open menu as user types
109
+ input.addEventListener('input', (e) => {
110
+ updateMenu(e.target.value);
111
+ if (!menu.open) {
112
+ menu.open = true;
113
+ }
114
+ });
115
+
116
+ // Re-open full menu list when user clicks or focuses inside the input
117
+ input.addEventListener('focus', () => {
118
+ updateMenu(input.value);
119
+ menu.open = true;
120
+ });
121
+
122
+ // Handle selection event from the Material menu
123
+ menu.addEventListener('close-menu', (e) => {
124
+ // e.detail.item holds the clicked md-menu-item instance
125
+ const selectedItem = e.detail.item;
126
+ if (selectedItem && selectedItem.dataset.value) {
127
+ input.value = selectedItem.dataset.value;
128
+ // Optionally trigger a change event for form tracking
129
+ input.dispatchEvent(new Event('change'));
130
+ }
131
+ });
132
+
133
+ // Close the dropdown cleanly if the user hits "Escape" or "Enter" inside input
134
+ input.addEventListener('keydown', (e) => {
135
+ if (e.key === 'Escape' || e.key === 'Enter') {
136
+ menu.open = false;
137
+ if(e.key === 'Enter') input.blur();
138
+ }
139
+ });
140
+
141
+ // Initial population of the menu
142
+ updateMenu();
143
+ </script>
144
+ </body>
145
+ </html>
@@ -1,6 +1,100 @@
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.4';
3
+ const serviceVersion = '4.5.8';
4
+
5
+ const cacheList = [ // The files we need.
6
+ "/",
7
+ "/index.html", // Just in case anyone is specifying that.
8
+ "favicon.ico",
9
+ "manifest.json",
10
+ "package.json",
11
+
12
+ "javascripts/versions.js",
13
+ "javascripts/main.js",
14
+ "javascripts/display.js",
15
+ "javascripts/conversation.js",
16
+ "javascripts/alert.js",
17
+ "javascripts/map.js",
18
+ "javascripts/hashtags.js",
19
+ "javascripts/s2.js",
20
+ "javascripts/agent.js",
21
+ "javascripts/translations.js",
22
+ "javascripts/service-manager.js",
23
+ "javascripts/p2pWebNetwork.js",
24
+
25
+ "stylesheets/style.css",
26
+
27
+ "images/qr-scan.svg",
28
+ "images/share.svg",
29
+ "images/recenter.svg",
30
+ "images/civil-defense-122.png",
31
+ "images/civil-defense-192.png",
32
+ "images/civil-defense-240.png",
33
+ "images/civil-defense-512.png",
34
+
35
+ "axona-protocol/src/index.js",
36
+ "axona-protocol/src/errors.js",
37
+ "axona-protocol/src/bridgeDirectory.js",
38
+ "axona-protocol/src/contracts/Transport.js",
39
+ "axona-protocol/src/contracts/DHT.js",
40
+ "axona-protocol/src/contracts/BootstrapService.js",
41
+ "axona-protocol/src/identity/index.js",
42
+ "axona-protocol/src/identity/nodeid.js",
43
+ "axona-protocol/src/pow/pow.js",
44
+ "axona-protocol/src/dht/AxonaPeer.js",
45
+ "axona-protocol/src/dht/AxonaDomain.js",
46
+ "axona-protocol/src/dht/DHTNode.js",
47
+ "axona-protocol/src/dht/NeuronNode.js",
48
+ "axona-protocol/src/dht/Synapse.js",
49
+ "axona-protocol/src/dht/Subscription.js",
50
+ "axona-protocol/src/pubsub/AxonaManager.js",
51
+ "axona-protocol/src/pubsub/authorClass.js",
52
+ "axona-protocol/src/pubsub/kill.js",
53
+ "axona-protocol/src/pubsub/touch.js",
54
+ "axona-protocol/src/pubsub/post.js",
55
+ "axona-protocol/src/pubsub/unpub.js",
56
+ "axona-protocol/src/pubsub/envelope.js",
57
+ "axona-protocol/src/pubsub/ed25519.js",
58
+ "axona-protocol/src/pubsub/metrics.js",
59
+ "axona-protocol/src/utils/region-names.js",
60
+ "axona-protocol/src/utils/geo.js",
61
+ "axona-protocol/src/utils/s2.js",
62
+ "axona-protocol/src/utils/hexid.js",
63
+ "axona-protocol/src/transport/handshake.js",
64
+ "axona-protocol/src/transport/handshake-auth.js",
65
+ "axona-protocol/src/transport/wire.js",
66
+ "axona-protocol/src/transport/web/index.js",
67
+ "axona-protocol/src/transport/web/mesh.js",
68
+ "axona-protocol/src/transport/web/mesh-auth.js",
69
+ "axona-protocol/src/transport/web/webrtc.js",
70
+ "axona-protocol/src/transport/web/bridge.js",
71
+ "axona-protocol/src/transport/web/composite.js",
72
+ "axona-protocol/src/transport/sim/index.js",
73
+ "axona-protocol/src/transport/sim/network.js",
74
+ "axona-protocol/src/transport/sim/transport.js",
75
+ "axona-protocol/src/persistence/interface.js",
76
+ "axona-protocol/src/crypto/noble-ed25519.js",
77
+ "axona-protocol/std/index.js",
78
+ "axona-protocol/std/chunk.js",
79
+
80
+ "leaflet/leaflet.css",
81
+ "leaflet/leaflet-src.esm.js",
82
+ "leaflet/images/marker-icon.png",
83
+ "leaflet/images/marker-icon-2x.png",
84
+ "leaflet/images/marker-shadow.png",
85
+
86
+ "pica/pica.min.js",
87
+ "minidenticons/minidenticons.min.js",
88
+ "s2js/s2js.esm.js",
89
+ "bigfloat/esm/index.js",
90
+ "bigfloat/esm/BigFloat32",
91
+ "bigfloat/esm/BigFloat53",
92
+ "bigfloat/esm/BigComplex",
93
+ "bigfloat/esm/BaseInfo32",
94
+ "bigfloat/esm/util",
95
+
96
+ // TODO: the libraries
97
+ ];
4
98
 
5
99
  async function cacheFirst({request, event}) {
6
100
  // Handle request from any cache, else fetch and store it in serviceCache.
@@ -72,99 +166,8 @@ self.addEventListener('fetch', event => {
72
166
  async function cacheSource(version, event) { // Cache source in the given version.
73
167
  console.log(`service-worker ${serviceVersion} is caching source in cache ${version}.`);
74
168
  const cache = await caches.open(version);
75
- await cache.addAll([
76
- "/",
77
- "/index.html", // Just in case anyone is specifying that.
78
- "favicon.ico",
79
- "manifest.json",
80
- "package.json",
81
-
82
- "javascripts/versions.js",
83
- "javascripts/main.js",
84
- "javascripts/display.js",
85
- "javascripts/conversation.js",
86
- "javascripts/alert.js",
87
- "javascripts/map.js",
88
- "javascripts/hashtags.js",
89
- "javascripts/s2.js",
90
- "javascripts/agent.js",
91
- "javascripts/translations.js",
92
- "javascripts/service-manager.js",
93
- "javascripts/p2pWebNetwork.js",
94
-
95
- "stylesheets/style.css",
96
-
97
- "images/qr-scan.svg",
98
- "images/share.svg",
99
- "images/recenter.svg",
100
- "images/civil-defense-122.png",
101
- "images/civil-defense-192.png",
102
- "images/civil-defense-240.png",
103
- "images/civil-defense-512.png",
104
-
105
- "axona-protocol/src/index.js",
106
- "axona-protocol/src/errors.js",
107
- "axona-protocol/src/bridgeDirectory.js",
108
- "axona-protocol/src/contracts/Transport.js",
109
- "axona-protocol/src/contracts/DHT.js",
110
- "axona-protocol/src/contracts/BootstrapService.js",
111
- "axona-protocol/src/identity/index.js",
112
- "axona-protocol/src/identity/nodeid.js",
113
- "axona-protocol/src/pow/pow.js",
114
- "axona-protocol/src/dht/AxonaPeer.js",
115
- "axona-protocol/src/dht/AxonaDomain.js",
116
- "axona-protocol/src/dht/DHTNode.js",
117
- "axona-protocol/src/dht/NeuronNode.js",
118
- "axona-protocol/src/dht/Synapse.js",
119
- "axona-protocol/src/dht/Subscription.js",
120
- "axona-protocol/src/pubsub/AxonaManager.js",
121
- "axona-protocol/src/pubsub/authorClass.js",
122
- "axona-protocol/src/pubsub/kill.js",
123
- "axona-protocol/src/pubsub/touch.js",
124
- "axona-protocol/src/pubsub/post.js",
125
- "axona-protocol/src/pubsub/unpub.js",
126
- "axona-protocol/src/pubsub/envelope.js",
127
- "axona-protocol/src/pubsub/ed25519.js",
128
- "axona-protocol/src/pubsub/metrics.js",
129
- "axona-protocol/src/utils/region-names.js",
130
- "axona-protocol/src/utils/geo.js",
131
- "axona-protocol/src/utils/s2.js",
132
- "axona-protocol/src/utils/hexid.js",
133
- "axona-protocol/src/transport/handshake.js",
134
- "axona-protocol/src/transport/handshake-auth.js",
135
- "axona-protocol/src/transport/wire.js",
136
- "axona-protocol/src/transport/web/index.js",
137
- "axona-protocol/src/transport/web/mesh.js",
138
- "axona-protocol/src/transport/web/mesh-auth.js",
139
- "axona-protocol/src/transport/web/webrtc.js",
140
- "axona-protocol/src/transport/web/bridge.js",
141
- "axona-protocol/src/transport/web/composite.js",
142
- "axona-protocol/src/transport/sim/index.js",
143
- "axona-protocol/src/transport/sim/network.js",
144
- "axona-protocol/src/transport/sim/transport.js",
145
- "axona-protocol/src/persistence/interface.js",
146
- "axona-protocol/src/crypto/noble-ed25519.js",
147
- "axona-protocol/std/index.js",
148
- "axona-protocol/std/chunk.js",
149
-
150
- "leaflet/leaflet.css",
151
- "leaflet/leaflet-src.esm.js",
152
- "leaflet/images/marker-icon.png",
153
- "leaflet/images/marker-icon-2x.png",
154
- "leaflet/images/marker-shadow.png",
155
-
156
- "pica/pica.min.js",
157
- "minidenticons/minidenticons.min.js",
158
- "s2js/s2js.esm.js",
159
- "bigfloat/esm/index.js",
160
- "bigfloat/esm/BigFloat32",
161
- "bigfloat/esm/BigFloat53",
162
- "bigfloat/esm/BigComplex",
163
- "bigfloat/esm/BaseInfo32",
164
- "bigfloat/esm/util",
165
-
166
- // TODO: the libraries
167
- ].map(name => new Request(name, {cache: 'no-store'}))); // Might not be necessary, but if any browsers insist on their own caching...
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...
168
171
 
169
172
  await Promise.all([
170
173
  // These are referenced within material web, but missing. Turns out we don't need them,
package/server/app.js CHANGED
@@ -46,6 +46,12 @@ const argv = yargs(hideBin(process.argv))
46
46
  default: 5,
47
47
  description: "Additional variable seconds (+/- variableSpacing/2) to add to fixedSpacing between each portal."
48
48
  })
49
+ .option('info', {
50
+ alias: 'i',
51
+ type: 'boolean',
52
+ default: true,
53
+ description: "Run with info logging."
54
+ })
49
55
  .option('verbose', {
50
56
  alias: 'v',
51
57
  type: 'boolean',
@@ -94,20 +100,28 @@ if (cluster.isPrimary) { // Parent process with portal webserver through which c
94
100
  }));
95
101
 
96
102
  app.listen(port);
97
- console.log('Listening on', port, 'and starting', argv.nPortals, 'nodes.');
103
+ console.log(new Date(), `Listening on ${port} and starting ${argv.nPortals} nodes on ${logicalCores} ${cpus()[0].model} logical cores.`);
98
104
  for (let i = 0; i < argv.nPortals; i++) {
99
105
  cluster.fork();
100
- await new Promise(resolve => setTimeout(resolve, 1e3));
106
+ await new Promise(resolve => setTimeout(resolve, 2e3)); // Number chosen to give an unspikey rise in packets/s.
101
107
  }
102
108
  } else {
103
109
  process.title = 'axona-starting';
104
110
  const { P2PWebNetwork, location } = await import('../index.js');
105
- const network = await P2PWebNetwork.create({region: location});
111
+ const network = await P2PWebNetwork.create({
112
+ region: location,
113
+ infoLogger: (...rest) => argv.info && console.log(new Date(), ...rest),
114
+ debugLogger: (...rest) => argv.verbose && console.log(new Date(), ...rest)
115
+ });
106
116
  process.title = 'axona-' + network.nodeIdentity.id;
107
- //let update = setInterval(() => network.info(network.peer.health().axonRoles.length, 'axons'), 10e3);
117
+ let update = setInterval(() => {
118
+ const roles = network.peer.health().axonRoles;
119
+ network.debug(roles.length, 'axons',
120
+ roles.reduce((total, role) => total + (role.isRoot ? 1 : 0), 0), 'roots');
121
+ }, 10e3);
108
122
  process.on('SIGINT', async () => { // Leave the network politely.
109
123
  console.log(process.title, 'Shutdown for Ctrl+C');
110
- //clearInterval(update)
124
+ clearInterval(update)
111
125
  await network.disconnect();
112
126
  process.exit(0);
113
127
  });
@@ -1,4 +1,4 @@
1
- const { describe, it, expect, beforeAll, afterAll, BigInt } = globalThis;
1
+ const { describe, it, expect, expectAsync, beforeAll, afterAll, BigInt } = globalThis;
2
2
  import { Conversation } from '../public/javascripts/conversation.js';
3
3
 
4
4
  describe("Conversation", function () {
@@ -6,23 +6,26 @@ describe("Conversation", function () {
6
6
  let conversation;
7
7
  const tag = '123';
8
8
  const payload = 'cake';
9
- beforeAll(function () {
9
+ beforeAll(async function () {
10
10
  agent = {handle: 'alice'};
11
- conversation = Conversation.ensure({tag, payload, agent});
11
+ conversation = await Conversation.ensure({tag, payload, agent});
12
12
  });
13
13
  describe("creation", function () {
14
14
  it("initializes properties.", function () {
15
15
  expect(conversation.agent).toBe(agent);
16
16
  expect(conversation.payload).toBe(payload);
17
17
  });
18
- it("remembers conversations with the same tag.", function () {
19
- expect(Conversation.ensure({tag, payload, agent})).toBe(conversation);
18
+ it("remembers conversations with the same tag.", async function () {
19
+ expect(await Conversation.ensure({tag, payload, agent})).toBe(conversation);
20
20
  });
21
- it("Properties can be ommitted for existing tag.", function () {
22
- expect(Conversation.ensure({tag, payload})).toBe(conversation);
21
+ it("Properties can be ommitted for existing tag.", async function () {
22
+ expect(await Conversation.ensure({tag, payload})).toBe(conversation);
23
23
  });
24
- it("rejects changes by default.", function () {
25
- expect(() => Conversation.ensure({tag, payload: 'other', agent})).toThrow();
24
+ it("rejects changes by default.", async function () {
25
+ await expectAsync(Conversation.ensure({tag, payload: 'other', agent})).toBeRejected();
26
+ });
27
+ it("knows container is constructor.", function () {
28
+ expect(conversation.container).toBe(Conversation);
26
29
  });
27
30
  describe("caching", function () {
28
31
  let keep = true;
@@ -31,68 +34,68 @@ describe("Conversation", function () {
31
34
  update() { return keep && this; }
32
35
  initialize() { return keep && this; }
33
36
  }
34
- it("ends with explicit removal.", function () {
35
- let initial = Conversation.ensure({tag, agent, payload});
37
+ it("ends with explicit removal.", async function () {
38
+ let initial = await Conversation.ensure({tag, agent, payload});
36
39
  expect(initial).toBeTruthy();
37
40
  expect(Conversation.getItem(tag)).toBe(initial);
38
41
  expect(Conversation.removeItem(tag)).toBe(initial);
39
42
  expect(Conversation.getItem(tag)).toBeFalsy();
40
43
  });
41
44
  describe("deleting data", function () {
42
- it("keeps new if not deleting.", function () {
45
+ it("keeps new if not deleting.", async function () {
43
46
  tag = 'keep';
44
- let initial = Conversation.ensure({tag, agent, payload});
47
+ let initial = await Conversation.ensure({tag, agent, payload});
45
48
  expect(initial).toBeTruthy();
46
- expect(Conversation.ensure({tag, agent, payload})).toBe(initial);
49
+ expect(await Conversation.ensure({tag, agent, payload})).toBe(initial);
47
50
  expect(Conversation.getItem(tag)).toBe(initial);
48
51
  });
49
- it("is skipped if delete data.", function () {
52
+ it("is skipped if delete data.", async function () {
50
53
  tag = 'skip';
51
- let initial = Conversation.ensure({tag, agent});
54
+ let initial = await Conversation.ensure({tag, agent});
52
55
  expect(initial).toBeFalsy();
53
56
  expect(Conversation.getItem(tag)).toBeFalsy();
54
57
  });
55
- it("is removed if delete data.", function () {
58
+ it("is removed if delete data.", async function () {
56
59
  tag = 'skip';
57
- let initial = Conversation.ensure({tag, agent, payload});
60
+ let initial = await Conversation.ensure({tag, agent, payload});
58
61
  expect(initial).toBeTruthy();
59
62
  expect(Conversation.getItem(tag)).toBeTruthy();
60
- Conversation.ensure({tag, agent});
63
+ await Conversation.ensure({tag, agent});
61
64
  expect(Conversation.getItem(tag)).toBeFalsy();
62
65
  });
63
66
  });
64
67
  describe("null/item caching convention", function () {
65
68
  // Do we really need/want this convetion?
66
- it("keeps if initialize answers conversation.", function () {
69
+ it("keeps if initialize answers conversation.", async function () {
67
70
  keep = true;
68
71
  tag = 'keepA';
69
- let initial = CacheConversation.ensure({tag, agent, payload});
72
+ let initial = await CacheConversation.ensure({tag, agent, payload});
70
73
  expect(initial).toBeTruthy();
71
- expect(CacheConversation.ensure({tag, agent, payload})).toBe(initial);
74
+ expect(await CacheConversation.ensure({tag, agent, payload})).toBe(initial);
72
75
  expect(CacheConversation.getItem(tag)).toBe(initial);
73
76
  });
74
- it("is skipped if initialize answers falsy.", function () {
77
+ it("is skipped if initialize answers falsy.", async function () {
75
78
  keep = false;
76
79
  tag = 'skipA';
77
- let initial = CacheConversation.ensure({tag, agent, payload});
80
+ let initial = await CacheConversation.ensure({tag, agent, payload});
78
81
  expect(initial).toBeFalsy();
79
82
  expect(CacheConversation.getItem(tag)).toBeFalsy();
80
83
  });
81
- it("keeps existing if update answers conversation.", function () {
84
+ it("keeps existing if update answers conversation.", async function () {
82
85
  keep = true;
83
86
  tag = 'keepB';
84
- let initial = CacheConversation.ensure({tag, agent, payload});
85
- expect(CacheConversation.ensure({tag, agent, payload})).toBe(initial);
86
- expect(CacheConversation.ensure({tag, agent, payload})).toBe(initial);
87
- expect(CacheConversation.getItem(tag)).toBe(initial);
87
+ let initial = await CacheConversation.ensure({tag, agent, payload});
88
+ expect(await CacheConversation.ensure({tag, agent, payload})).toBe(initial);
89
+ expect(await CacheConversation.ensure({tag, agent, payload})).toBe(initial);
90
+ expect(await CacheConversation.getItem(tag)).toBe(initial);
88
91
  });
89
- it("destroys existing if update answers falsy.", function () {
92
+ it("destroys existing if update answers falsy.", async function () {
90
93
  keep = true;
91
94
  tag = 'skipB';
92
- let initial = CacheConversation.ensure({tag, agent, payload});
93
- expect(CacheConversation.ensure({tag, agent, payload})).toBe(initial);
95
+ let initial = await CacheConversation.ensure({tag, agent, payload});
96
+ expect(await CacheConversation.ensure({tag, agent, payload})).toBe(initial);
94
97
  keep = false;
95
- expect(CacheConversation.ensure({tag, agent, payload})).toBeFalsy();
98
+ expect(await CacheConversation.ensure({tag, agent, payload})).toBeFalsy();
96
99
  expect(CacheConversation.getItem(tag)).toBeFalsy();
97
100
  });
98
101
  });
@@ -100,11 +103,11 @@ describe("Conversation", function () {
100
103
  });
101
104
 
102
105
  describe("replies", function () {
103
- beforeAll(function () {
104
- conversation.ensure({payload: "second", issuedTime: 3, tag: 'z'});
105
- conversation.ensure({payload: "deleted", issuedTime: 2, tag: 'y'});
106
- conversation.ensure({payload: "first", issuedTime: 1, tag: 'x'});
107
- conversation.ensure({payload: null, issuedTime: 4, tag: 'y'});
106
+ beforeAll(async function () {
107
+ await conversation.ensure({payload: "second", issuedTime: 3, tag: 'z'});
108
+ await conversation.ensure({payload: "deleted", issuedTime: 2, tag: 'y'});
109
+ await conversation.ensure({payload: "first", issuedTime: 1, tag: 'x'});
110
+ await conversation.ensure({payload: null, issuedTime: 4, tag: 'y'});
108
111
  });
109
112
  it("adds replies in timestamp order.", function () {
110
113
  expect(conversation.items.map(reply => reply.payload)).toEqual(["first", "second"]);
@@ -112,5 +115,8 @@ describe("Conversation", function () {
112
115
  it("removes deleted replies.", function () { // fixme eachReply
113
116
  expect(conversation.items.find(reply => !reply.payload)).toBeFalsy();
114
117
  });
118
+ it("knows container is conversation.", function () {
119
+ expect(conversation.items[0].container).toBe(conversation);
120
+ });
115
121
  });
116
122
  });