@yz-social/civildefense.io 4.5.23 → 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 CHANGED
@@ -6,7 +6,7 @@ CivilDefense.io lets you report an immediate concern to the public by tapping it
6
6
  The locations are shared over anonymous p2p with other users in your area, then fade away over 24 hours.
7
7
  There is no login and no global tracking of your Internet address or physical location.
8
8
 
9
- See [here](https://civildefense.io/about/TheCivilDefenseApp.html).
9
+ See [Overview](https://civildefense.io/about/en.html), [Functional Spec](https://docs.google.com/document/d/1Y621bToMLHyyx6gp4YK8C17jSejMoqpkNQ-R1EZdrEA/edit?usp=sharing), and [Roadmap](https://docs.google.com/document/d/1vwS_oQ6pYw-dn05nsaXi-bda1j-wVVqRdqr7IHnIaf4/edit?usp=sharing).
10
10
 
11
11
 
12
12
  ## The Implementation
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.23",
4
+ "version": "4.5.28",
5
5
  "keywords": [
6
6
  "map",
7
7
  "browser",
Binary file
@@ -42,18 +42,25 @@
42
42
  </div>
43
43
 
44
44
  <div class="carousel-slide">
45
- <img src="flood-fire-ice.png" alt="Map view showing alert markers scattered across a neighborhood">
45
+ <a href="/?tags=%F0%9F%A9%B7%20community%20%20DEMO,%F0%9F%91%81%EF%B8%8F%20observer%20DEMO,%F0%9F%A7%B0%20utility%20DEMO&lat=44.942473404432015&lng=-93.26251878766926&z=14" target="civildefense"><img src="streets-of-minneapolis.jpg" alt="Map view showing alert markers scattered across a neighborhood"></a>
46
46
  <div class="carousel-caption">Like Twitter, you create the topics.<br>But you only get alerts based on your current map.<br>Like Snapchat, alerts fade away after 24 hours.</div>
47
47
  </div>
48
48
 
49
49
  <div class="carousel-slide">
50
- <img src="conversation-and-image.png" alt="Map with an open alert popup showing a photo and comment thread">
51
- <div class="carousel-caption">Click a marker to read and add comments and attachments.</div>
50
+ <a href="/?tags=%F0%9F%91%81%EF%B8%8F%20observer%20DEMO&lat=44.95924556664652&lng=-93.24730055745023&z=16" target="civildefense"><img src="checkpoint.jpg" alt="Map with an open alert popup showing a photo and comment thread"></a>
51
+ <div class="carousel-caption">
52
+ <span>Click a marker to read and add comments and attachments.</span>
53
+ <a href="/?tags=%F0%9F%91%81%EF%B8%8F%20observer%20DEMO&lat=44.95924556664652&lng=-93.24730055745023&z=16" target="civildefense">Show me!</a>
54
+ </div>
52
55
  </div>
53
56
 
54
57
  <div class="carousel-slide">
55
- <img src="streaming-radio.png" alt="North America map with playable streaming radio station markers">
56
- <div class="carousel-caption">People control the topics, and they do not have to be about safety. Here are playable streaming radio stations by category.</div>
58
+ <a href="/?tags=%F0%9F%8E%BC%20classical,%F0%9F%8E%A7%20edm,%F0%9F%8E%A4%20hiphop,%F0%9F%8E%99%EF%B8%8F%20news,%F0%9F%8E%B5%20pop&lat=47.51951505778901&lng=1.6779639902073742&z=6" target="civildefense"><img src="radio-europe.jpg" alt="Map of Western Europe with playable streaming radio station markers"></a>
59
+ <div class="carousel-caption">
60
+ <span>People control the topics, and they do not have to be about safety.</span>
61
+ <span>Here are playable streaming radio stations by category.</span>
62
+ <a href="/?tags=%F0%9F%8E%BC%20classical,%F0%9F%8E%A7%20edm,%F0%9F%8E%A4%20hiphop,%F0%9F%8E%99%EF%B8%8F%20news,%F0%9F%8E%B5%20pop&lat=47.51951505778901&lng=1.6779639902073742&z=6" target="civildefense">Show me!</a>
63
+ </div>
57
64
  </div>
58
65
 
59
66
  <div class="carousel-slide">
@@ -94,7 +101,7 @@
94
101
  <!-- <li>Alerts disappear after 24 hours—this is not an archive or long-term storage system. (This Winter, users will be able to download tamper-evident reports for use in other systems.)</li> -->
95
102
  </ul>
96
103
 
97
- <a href="/?lang=en" class="launch-btn" target="civildefense">
104
+ <a href="/?tags=%F0%9F%8E%B8%20classic%20rock,%F0%9F%8E%BC%20classical,%F0%9F%A4%A0%20country,%F0%9F%8E%B7%20jazz,%F0%9F%8E%99%EF%B8%8F%20news,%F0%9F%8E%B5%20pop&z=5&lang=en" class="launch-btn" target="civildefense">
98
105
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
99
106
  Try it!
100
107
  </a>
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>La Aplicación de Defensa Civil</title>
6
+ <title>La App de Civil Defense</title>
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
@@ -15,8 +15,10 @@
15
15
  <!-- ── HERO ──────────────────────────────────────────────────── -->
16
16
  <header class="hero">
17
17
  <div class="hero-inner">
18
- <img class="hero-logo" src="civil-defense.png" alt="Logo de Defensa Civil">
19
- <h1>La Aplicación de <span>Defensa Civil</span></h1>
18
+ <img class="hero-logo" src="civil-defense.png" alt="Logotipo de Civil Defense">
19
+ <h1>La App de <span>Civil Defense</span></h1>
20
+ <!-- <div class="hero-divider"></div> -->
21
+ <!-- <p class="hero-tagline">Realtime crowdmapping that is resistant to tracking, censorship, and takedown</p> -->
20
22
  </div>
21
23
  <div class="language-selector">
22
24
  <label for="language">Idioma:</label>
@@ -28,7 +30,7 @@
28
30
  </header>
29
31
 
30
32
  <!-- ── CAROUSEL ──────────────────────────────────────────────── -->
31
- <section class="carousel-section" aria-label="Capturas de pantalla de la aplicación">
33
+ <section class="carousel-section" aria-label="Capturas de pantalla de la app">
32
34
 
33
35
  <div class="carousel-wrap">
34
36
  <div class="carousel-track-outer">
@@ -36,34 +38,50 @@
36
38
 
37
39
  <div class="carousel-slide">
38
40
  <img src="broadcast.png" alt="Una aplicación móvil mostrando alertas">
39
- <div class="carousel-caption">Mapeo colaborativo en tiempo real resistente al rastreo, la censura y el bloqueo.</div>
41
+ <div class="carousel-caption">Mapeo colectivo en tiempo real, resistente al rastreo, la censura y la eliminación.</div>
40
42
  </div>
41
43
 
42
44
  <div class="carousel-slide">
43
- <img src="flood-fire-ice.png" alt="Vista del mapa con marcadores de alerta dispersos por un vecindario">
45
+ <a href="/?tags=%F0%9F%A9%B7%20community%20%20DEMO,%F0%9F%91%81%EF%B8%8F%20observer%20DEMO,%F0%9F%A7%B0%20utility%20DEMO&lat=44.942473404432015&lng=-93.26251878766926&z=14" target="civildefense"><img src="streets-of-minneapolis.jpg" alt="Vista de mapa que muestra marcadores de alerta dispersos por un vecindario"></a>
44
46
  <div class="carousel-caption">Como en Twitter, tú creas los temas.<br>Pero solo recibes alertas según tu mapa actual.<br>Como en Snapchat, las alertas desaparecen después de 24 horas.</div>
45
47
  </div>
46
48
 
47
49
  <div class="carousel-slide">
48
- <img src="conversation-and-image.png" alt="Mapa con una ventana emergente de alerta abierta mostrando una foto y comentarios">
49
- <div class="carousel-caption">Haz clic en un marcador para leer y añadir comentarios y archivos adjuntos.</div>
50
+ <a href="/?tags=%F0%9F%91%81%EF%B8%8F%20observer%20DEMO&lat=44.95924556664652&lng=-93.24730055745023&z=16" target="civildefense"><img src="checkpoint.jpg" alt="Mapa con una ventana de alerta abierta que muestra una foto y un hilo de comentarios"></a>
51
+ <div class="carousel-caption">
52
+ <span>Haz clic en un marcador para leer y agregar comentarios y archivos adjuntos.</span>
53
+ <a href="/?tags=%F0%9F%91%81%EF%B8%8F%20observer%20DEMO&lat=44.95924556664652&lng=-93.24730055745023&z=16" target="civildefense">Muéstrame!</a>
54
+ </div>
50
55
  </div>
51
56
 
52
57
  <div class="carousel-slide">
53
- <img src="streaming-radio.png" alt="Mapa de Norteamérica con marcadores de estaciones de radio en streaming reproducibles">
54
- <div class="carousel-caption">Las personas controlan los temas, y no tienen que ser sobre seguridad. Aquí hay estaciones de radio en streaming reproducibles por categoría.</div>
58
+ <a href="/?tags=%F0%9F%8E%BC%20classical,%F0%9F%8E%A7%20edm,%F0%9F%8E%A4%20hiphop,%F0%9F%8E%99%EF%B8%8F%20news,%F0%9F%8E%B5%20pop&lat=47.51951505778901&lng=1.6779639902073742&z=6" target="civildefense"><img src="radio-europe.jpg" alt="Mapa de Europa Occidental con marcadores de estaciones de radio en streaming reproducibles"></a>
59
+ <div class="carousel-caption">
60
+ <span>Las personas controlan los temas, y no tienen que tratar sobre seguridad.</span>
61
+ <span>Aquí hay estaciones de radio en streaming reproducibles por categoría.</span>
62
+ <a href="/?tags=%F0%9F%8E%BC%20classical,%F0%9F%8E%A7%20edm,%F0%9F%8E%A4%20hiphop,%F0%9F%8E%99%EF%B8%8F%20news,%F0%9F%8E%B5%20pop&lat=47.51951505778901&lng=1.6779639902073742&z=6" target="civildefense">Muéstrame!</a>
63
+ </div>
55
64
  </div>
56
65
 
57
66
  <div class="carousel-slide">
58
67
  <video controls src="CivilDefense.mp4"></video>
59
- <div class="carousel-caption">Un video de presentación de 30 segundos generado con IA. (En Inglés.)</div>
68
+ <div class="carousel-caption">Un video de 30 segundos con ambientación generada por IA.</div>
60
69
  </div>
61
70
 
71
+ <!-- <div class="carousel-slide"> -->
72
+ <!-- <img src="hero.png" alt="One man with shoping bags in Tiananmen Square, facing an enormous column of tanks."> -->
73
+ <!-- <div class="carousel-caption">Bear witness.</div> -->
74
+ <!-- </div> -->
75
+
76
+ <!-- <div class="carousel-slide"> -->
77
+ <!-- <img src="nazis.png" alt="WWII-style poster exhorting the public to use Civil Defense in case of Nazi attack."> -->
78
+ <!-- </div> -->
79
+
62
80
  </div>
63
81
  </div>
64
82
 
65
83
  <button class="carousel-btn prev" id="prevBtn" aria-label="Diapositiva anterior">&#8249;</button>
66
- <button class="carousel-btn next" id="nextBtn" aria-label="Diapositiva siguiente">&#8250;</button>
84
+ <button class="carousel-btn next" id="nextBtn" aria-label="Siguiente diapositiva">&#8250;</button>
67
85
 
68
86
  <div class="carousel-dots" id="carouselDots" role="tablist" aria-label="Posición del carrusel"></div>
69
87
  </div>
@@ -75,67 +93,71 @@
75
93
  <h1>Qué Puedes Hacer Con Ella</h1>
76
94
 
77
95
  <ul>
78
- <li>Advertir a otros sobre peligros tocando el mapa.</li>
79
- <li>Añadir comentarios, fotos, videos u otros archivos adjuntos.</li>
80
- <li>Recibir notificaciones de nuevas alertas cerca de ti o en tu mapa actual.</li>
81
- <li>Compartir en redes sociales, por mensaje de texto o correo electrónico.</li>
82
- <li>Discutir una alerta mediante videoconferencia segura. (Disponible este invierno.)</li>
96
+ <li>Advierte a otros sobre un peligro tocando el mapa.</li>
97
+ <li>Agrega comentarios, fotos, videos u otros archivos adjuntos.</li>
98
+ <li>Recibe notificaciones de nuevas alertas cerca de ti o en tu mapa actual.</li>
99
+ <li>Comparte en redes sociales, mensaje de texto o correo electrónico.</li>
100
+ <li>Discute una alerta mediante videoconferencia segura. (Disponible este otoño.)</li>
101
+ <!-- <li>Alerts disappear after 24 hours—this is not an archive or long-term storage system. (This Winter, users will be able to download tamper-evident reports for use in other systems.)</li> -->
83
102
  </ul>
84
103
 
85
- <a href="/?lang=es" class="launch-btn" target="civildefense">
104
+ <a href="/?tags=%F0%9F%8E%B8%20classic%20rock,%F0%9F%8E%BC%20classical,%F0%9F%A4%A0%20country,%F0%9F%8E%B7%20jazz,%F0%9F%8E%99%EF%B8%8F%20news,%F0%9F%8E%B5%20pop&z=5&lang=es" class="launch-btn" target="civildefense">
86
105
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
87
106
  ¡Pruébala!
88
107
  </a>
89
108
 
90
- <h1>Transmite con Seguridad</h1>
109
+ <!-- <hr class="rule"> -->
110
+
111
+ <h1>Difunde de Forma Segura</h1>
91
112
 
92
113
  <h2>Resiste el Rastreo</h2>
93
114
  <ul>
94
- <li>No puedes ser identificado como persona porque no hay inicio de sesión, registro ni cuentas. No se utiliza número de teléfono ni correo electrónico. No hay tienda de aplicaciones que registre las descargas. Los usuarios pueden optar por permitir que otros los reconozcan en múltiples interacciones, sin revelar su identidad real.</li>
95
- <li>Es extremadamente difícil identificar tu dirección IP o los patrones de red, porque las alertas se distribuyen de teléfono a teléfono a través de una red entre pares.</li>
96
- <li>Las notificaciones se envían directamente a través de la aplicación en primer plano en tu teléfono, sin pasar por el servicio de mensajería de Apple o Google. (Las notificaciones con la aplicación en segundo plano estarán disponibles este invierno.)</li>
97
- <li>Cada alerta desaparece de toda la red después de 24 horas. No se recopilan registros de mensajes, comentarios ni temas solicitados, y este no es un sistema de archivo ni de almacenamiento a largo plazo.</li>
115
+ <li>No puedes ser identificado como persona porque no hay inicio de sesión, registro ni cuentas. No se usa ningún número de teléfono ni correo electrónico. Ninguna tienda de aplicaciones registra las descargas. Los usuarios pueden optar por dejar que otros los reconozcan en múltiples interacciones sin revelar su identidad real.</li>
116
+ <li>Es extremadamente difícil identificar tu dirección IP o tus patrones de red, porque las alertas se distribuyen de teléfono a teléfono a través de una red entre pares (peer-to-peer).</li>
117
+ <li>Las notificaciones se envían directamente a través de la app en primer plano en tu teléfono, sin pasar por el servicio de mensajería de Apple o Google. (Las notificaciones en segundo plano estarán disponibles este invierno.)</li>
118
+ <li>Cada alerta desaparece de toda la red después de 24 horas. No se recopilan registros de mensajes, comentarios o temas solicitados, y este no es un sistema de archivo ni de almacenamiento a largo plazo.</li>
98
119
  </ul>
99
120
 
100
121
  <h2>Resiste la Censura</h2>
101
122
  <ul>
102
- <li>Ningún usuario puede ser censurado porque no hay cuentas. Nadie puede robar la información de tu cuenta porque no existe.</li>
103
- <li>Ningún contenido puede ser censurado; no hay una organización central que controle la red o tenga poder para modificar el contenido.</li>
123
+ <li>Ningún usuario puede ser censurado porque no existen cuentas. Nadie puede robar la información de tu cuenta porque no la hay.</li>
124
+ <li>Ningún contenido puede ser censurado; no existe una organización central que controle la red o tenga poder para alterar el contenido.</li>
104
125
  </ul>
105
126
 
106
- <h2>Resiste el Bloqueo</h2>
127
+ <h2>Resiste el Cierre</h2>
107
128
  <ul>
108
- <li>Ninguna tienda de aplicaciones puede bloquear Defensa Civil: la aplicación es simplemente una página web.</li>
109
- <li>Bloquear servidores web o nombres de dominio no puede impedir el acceso a quienes ya la tienen, porque la aplicación guarda en caché todo lo que necesita en el primer uso. El <a href="https://github.com/yz-social/civildefense.io" target="yz.other" rel="noopener">código fuente abierto</a> está disponible para que cualquiera pueda alojar un espejo alternativo.</li>
110
- <li>No hay servidor de back-end ni centro de datos que bloquear. Todos los datos de alertas son completamente entre pares. Todos los espejos comparten esta red: una publicación en <a href="https://civildefense.io" target="civildefense">civildefense.io</a> aparece en el espejo de <a href="https://ki1r0y.com" target="ki1r0y" rel="noopener">ki1r0y.com</a> y viceversa.</li>
111
- <li>Bloquear el tráfico de red es extremadamente difícil porque los datos de bajo nivel son idénticos al tráfico de videoconferencia.</li>
112
- <li>Defensa Civil es muy resistente a los ataques de denegación de servicio. Sin embargo, requiere una conexión a Internet funcional y, por sí sola, no ofrece protección contra un corte completo de Internet.</li>
129
+ <li>Ninguna tienda de aplicaciones puede bloquear Civil Defense — la app es solo una página web.</li>
130
+ <li>Cerrar servidores web o nombres de dominio no puede bloquear el acceso de quienes ya lo tienen, porque la app almacena en caché todo lo que necesita desde el primer uso. El <a href="https://github.com/yz-social/civildefense.io" target="yz.other" rel="noopener">código de código abierto</a> está disponible para que cualquiera pueda alojar un espejo alternativo.</li>
131
+ <li>No existe un servidor backend ni un centro de datos que se pueda derribar. Todos los datos de las alertas son completamente peer-to-peer. Todos los espejos comparten esta red: una publicación en <a href="https://civildefense.io" target="civildefense">civildefense.io</a> aparece en el espejo <a href="https://ki1r0y.com" target="ki1r0y" rel="noopener">ki1r0y.com</a>, y viceversa.</li>
132
+ <li>Bloquear el tráfico de la red es extremadamente difícil, porque los datos de bajo nivel son idénticos al tráfico de videoconferencias.</li>
133
+ <li>Civil Defense es altamente resistente a los ataques de denegación de servicio (DoS). Sin embargo, requiere una conexión a Internet funcional, y por sí sola no ofrece ninguna protección contra un cierre total de Internet.</li>
113
134
  </ul>
135
+ <!-- <hr class="rule"> -->
114
136
 
115
137
  <h1>El Panorama General</h1>
116
- <p>La Defensa Civil opera en la <a href="https://github.com/axona-net/axona-protocol" target="yz.other" rel="noopener">red Axona</a>. Ambos son gratuitos y de código abierto.</p>
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
- <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>
138
+ <p>Civil Defense funciona sobre la red <a href="https://github.com/axona-net/axona-protocol" target="yz.other" rel="noopener">Axona</a>. Ambos son gratuitos y de código abierto.</p>
139
+ <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 peer-to-peer</a>, como <a href="https://en.wikipedia.org/wiki/Napster">Napster</a>. Cuenta con un mecanismo único de enrutamiento autorreparable que utiliza técnicas de aprendizaje neuronal para encontrar la mejor forma de distribuir las 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>
140
+ <p>También estamos desarrollando un completo sistema de criptografía preparado para la era cuántica, que añadirá grupos privados para Civil Defense y para cualquier software de la red. Creemos que existen muchas aplicaciones en las que los desarrolladores, los usuarios y el público en general se benefician de un software abierto y de redes descentralizadas y seguras sin tiendas de aplicaciones, servidores ni centros de datos. Más allá de Civil Defense, también estamos desarrollando <a href="https://en.wikipedia.org/wiki/Distributed_computing" target="yz.other" rel="noopener">colaboración multiusuario</a> a gran escala 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>
141
+
119
142
 
120
143
  <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">
144
+ <img src="serving-you.jpg" alt="Cartel de la Semana de la Defensa Civil - Sirviéndote en tiempos de emergencia">
122
145
  </div>
123
146
 
124
147
  <div class="centered">
125
- <img src="prevail.jpg" alt="Póster de Defensa Civil de la Guerra Fría: Preparar, proteger, prevalecer">
148
+ <img src="prevail.jpg" alt="Cartel de Defensa Civil de la Guerra Fría - Prepárate, Protege, Prevalece">
126
149
  </div>
127
150
 
128
151
  <div class="centered">
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.">
152
+ <img src="nazis.png" alt="Cartel estilo Segunda Guerra Mundial que exhorta al público a usar la Defensa Civil en caso de ataque nazi.">
130
153
  </div>
131
154
 
132
155
  </main>
133
156
 
134
-
135
157
  <!-- ── FOOTER ─────────────────────────────────────────────────── -->
136
158
  <footer>
137
- <p><a href="/?lang=es" target="civildefense">civildefense.io</a> &mdash; Mapeo colaborativo seguro y gratuito para poblaciones vulnerables.<br>
138
- Usamos con gratitud software abierto de:
159
+ <p><a href="/?lang=es" target="civildefense">civildefense.io</a> &mdash; Mapeo colectivo seguro y gratuito para poblaciones vulnerables.<br>
160
+ Agradecemos el uso de software abierto de:
139
161
  <a href="https://leafletjs.com/" target="yz.other" rel="noopener">Leaflet</a>,
140
162
  <a href="https://www.openstreetmap.org/" target="yz.other" rel="noopener">OpenStreetMap</a>,
141
163
  <a href="http://s2geometry.io" target="yz.other" rel="noopener">S2Geometry</a>,
Binary file
@@ -155,6 +155,7 @@ a:hover { color: var(--red); }
155
155
  border-top: 2px solid var(--red);
156
156
  min-height: 3.4rem;
157
157
  display: flex;
158
+ flex-direction: column;
158
159
  align-items: center;
159
160
  justify-content: center;
160
161
  }
package/public/index.html CHANGED
@@ -161,6 +161,17 @@
161
161
 
162
162
  <md-menu positioning="popover" id="popoverMenu"></md-menu>
163
163
  <ul class="combobox-listbox hidden" id="knownTagsListbox" role="listbox"></ul>
164
+
165
+ <section class="firstConversation">
166
+ <div class="teach share"></div>
167
+ <div class="teach correspondent"></div>
168
+ <div class="teach attach"></div>
169
+ </section>
170
+ <section class="firstPublish">
171
+ <div class="teach correspondent"></div>
172
+ <div class="teach changeHashtag"></div>
173
+ </section>
174
+
164
175
  </section>
165
176
  <script type="module" src="javascripts/main.js"></script>
166
177
  </body>
@@ -2,17 +2,20 @@ import * as L from 'leaflet';
2
2
  import { P2PWebNetwork } from './p2pWebNetwork.js';
3
3
  import { Int } from './translations.js';
4
4
  import { map, trackMap, showMessage } from './map.js';
5
- import { networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, openAbout, delay, osName } from './main.js';
5
+ import { networkPromise, resetInactivityTimer, notificationsAllowed, tooltip, clickTip, getText, openAbout, delay, osName } from './main.js';
6
6
  import { consume } from './display.js';
7
7
  import { Hashtags } from './hashtags.js';
8
8
  import { Agent } from './agent.js';
9
9
  import { Conversation, Reply } from './conversation.js';
10
- import { alertTopic, topicRegion, cellHex, topicCell } from './versions.js';
10
+ import { alertTopic, topicRegion, cellHex, topicCell, topicTag } from './versions.js';
11
11
  import { getContainingCells, getSmallestCellId, getSubdivision, findCoverCellsByMinMaxLatLng, cellContains, pointFromLatLng, cellFromCellID } from './s2.js';
12
12
  const { localStorage, getComputedStyle, URL, URLSearchParams, domtoimage } = globalThis;
13
13
 
14
14
 
15
15
  export function getShareableURL(tag = null, tags = Hashtags.getSubscribe()) { // Answer a url that reflects application state.
16
+ // Regardless of whether an alert tag is given, the receiving user is always given a lat/lng:
17
+ // - To position their map with the same coverage as the sender
18
+ // - In case the alert has expired, rolled over, or included in an aggregate.
16
19
  const params = new URLSearchParams(location.search);
17
20
  const zoom = map.getZoom();
18
21
  const { lat, lng } = map.getCenter();
@@ -118,20 +121,22 @@ export class Alert extends Conversation { // A wrapper around L.marker
118
121
  // Conversation.ensure is the subscription handler, and it keeps track of the instances by tag, initializing a new one if needed.
119
122
  initialize({topic, payload, hashtag, tag, agent, issuedTime, ...rest}) { // Make appropriate instance for a new individual tag, or update aggregate.
120
123
 
124
+ const eventName = topic.name;
125
+ const subscriptions = this.constructor.subscriptions;
121
126
  if (!Hashtags.isSubscribed(hashtag)) return null; // A subscribed event may have been in flight while unsubscribing. Caller destroys instance.
127
+ if (!subscriptions.hasOwnProperty(eventName)) return null; // An event may have been in flight while zooming or panning.
122
128
  const now = Date.now(),
123
129
  expiration = issuedTime + ttl,
124
130
  remaining = expiration - now;
125
131
  if (remaining < 0) return null; // Network shouldn't send us expired, but if it does, let its instance be destroyed.
126
132
 
127
- const eventName = topic.name;
128
133
  let keep = this; // Instance to be kept as marker.
129
134
  let aggregate = this.constructor.getAggregate(eventName); // If we already have one for this eventName
130
135
 
131
136
  // Each new initialization gets counted, which may be more than the network rollover.
132
137
  // We do not "back up" for deletions and expirations - i.e., subtract and possibly go back to individual alerts.
133
138
  // Note that initialize() will not be called for an event handler that has a tag (msgId) the same as one we have already seen for a different cell scale.
134
- this.constructor.subscriptions[eventName]++;
139
+ subscriptions[eventName]++;
135
140
 
136
141
  if (aggregate) {
137
142
  keep = null; // This new instance is superfluous. Tell ensure() to destroy it...
@@ -215,20 +220,16 @@ export class Alert extends Conversation { // A wrapper around L.marker
215
220
  console.log('updating subscriptions', {added, dropped, newKeys, oldKeys});
216
221
 
217
222
  // Before subscribing, as that that may bring in an alert with the same tag as one being cleared.
218
- if (this.aggregateLimit) this.transferOrClearEventMarkers(added, dropped, newKeys, oldKeys);
223
+ if (this.aggregateLimit) {
224
+ // TODO: more efficient way?
225
+ new Set(added.map(topicTag)).forEach(tag => {
226
+ const hasTag = topicName => topicTag(topicName) === tag;
227
+ this.transferOrClearEventMarkers(added.filter(hasTag), dropped.filter(hasTag), newKeys, oldKeys);
228
+ });
229
+ }
219
230
 
220
231
  for (const key of added) await subscribe(key, data => Alert.ensure(data));
221
232
  for (const key of dropped) await subscribe(key, null);
222
-
223
- // for (const alert of this.items) { // fixme remove
224
- // if (this.subscriptions[alert.eventName] == undefined) {
225
- // let kind = added.includes(alert.eventName) && 'added';
226
- // kind ||= dropped.includes(alert.eventName) && 'dropped';
227
- // kind ||= Object.keys(newKeys).includes(alert.eventName) && 'new';
228
- // kind ||= Object.keys(oldKeys).includes(alert.eventName) && 'old';
229
- // throw new Error(`${kind} ${alert.eventName} has no count after subscription update.`);
230
- // }
231
- // }
232
233
  });
233
234
  }
234
235
 
@@ -237,15 +238,16 @@ export class Alert extends Conversation { // A wrapper around L.marker
237
238
  // newKeys/oldKeys are a map of the currently subscribed eventName and the count of events for that cell+hashtag
238
239
  static subscriptions = {}; // maps currently active eventNames (<mumble>:<cellID>:<hashtag>) to count of event received for it.
239
240
  static aggregateLimit = parseInt(new URLSearchParams(location.search).get('max') || 100); // How many individuals are allowed before we aggregate.
241
+ static MAX_MAP_ZOOM = 19; // If the map is already zoomed to this, there's no place else to go.
240
242
  static cellCountOverLimit(eventName, countsDictionary = this.subscriptions) { // Have we received enough that we must show an aggregate?
241
- return countsDictionary[eventName] >= this.aggregateLimit;
243
+ return (countsDictionary[eventName] >= this.aggregateLimit) && (map.getZoom() < this.MAX_MAP_ZOOM);
242
244
  }
243
245
  static getAggregate(eventName) { // Answer the existing aggregate for this cell, if any.
244
246
  return this.getItem(eventName); // While individual Alerts are stored in items/conversations by tag, the tag for aggregate is the eventName.
245
247
  }
246
248
  logAlert(label = '') { // For debugging, when an individual or aggregate marker is clicked, show the alert, cell, and count in console.
247
- const {lat, lng, eventName} = this;
248
- console.warn(`${label} lat: ${lat}, lng: ${lng}, ${eventName}: ${this.constructor.subscriptions[eventName]}`);
249
+ const {lat, lng, tag, eventName} = this;
250
+ console.warn(`${label} lat: ${lat}, lng: ${lng}, ${eventName}: ${this.constructor.subscriptions[eventName]}, ${tag}`);
249
251
  }
250
252
  noteEventName(eventName) { // Be a part of the specified grouping.
251
253
  if (this.eventName === eventName) return;
@@ -259,9 +261,10 @@ export class Alert extends Conversation { // A wrapper around L.marker
259
261
  const latLng = s2.LatLng.fromPoint(point);
260
262
  return [latLng.lat * radiansToDegrees, latLng.lng * radiansToDegrees];
261
263
  });
262
- const border = this.cellBorder = L.polygon(corners, {color: 'red'});
264
+ const border = this.cellBorder = L.polygon(corners, {color: this.constructor.md_sys_color_secondary, fillOpacity: 0.1});
263
265
  border.addTo(map);
264
266
  }
267
+ static md_sys_color_secondary = '#BD2E2F';
265
268
  static forEachAlertOf(eventName, callback, alerts = this.items) { // Apply callback to each of alerts matching eventName.
266
269
  // TODO? Record a cell's Alert's more efficiently, so that we don't have to cycle through everything.
267
270
  alerts.forEach((alert, index, alerts) => (alert.eventName === eventName) && callback(alert, index, alerts));
@@ -293,8 +296,11 @@ export class Alert extends Conversation { // A wrapper around L.marker
293
296
  setTimeout(() => pin.classList.toggle('starting', false), 100);
294
297
  marker.unbindPopup();
295
298
  marker.off('click');
296
- marker.on('click', event => this.logAlert('FIXME go down one level'));
297
- tooltip(element, Int`Zoom in on multiple ${hashtag} alerts.`); // fixme Int.
299
+ marker.on('click', event => {
300
+ this.logAlert();
301
+ this.constructor.zoomOn(this.lat, this.lng, hashtag);
302
+ });
303
+ tooltip(element, Int`Zoom in on multiple alerts in this area.`);
298
304
  this.noteEventName(this.tag = eventName);
299
305
  if (tag !== eventName) {
300
306
  networkPromise.then(async contact => contact.subscribe({eventName: tag, region, handler: null})); // Unsubscribe from replies.
@@ -441,6 +447,13 @@ export class Alert extends Conversation { // A wrapper around L.marker
441
447
  if (this.publishing) { console.log('skiping overlapping publish'); return null; } // do not stack them up.
442
448
  try {
443
449
  this.publishing = true;
450
+ const cells = getContainingCells(lat, lng);
451
+ const eventNames = cells.map(cell => alertTopic(cell, hashtag));
452
+ if (payload && eventNames.some(eventName => this.getAggregate(eventName))) { // Attemtpt to publish where we are showing an aggregate.
453
+ // There could already be an alert there, so zoom instead.
454
+ this.zoomOn(lat, lng, hashtag);
455
+ return null;
456
+ }
444
457
 
445
458
  const contact = await networkPromise; // subtle: The rest of this all happens synchronously, with any null payloads definitely first.
446
459
  let oldCells = null, oldHash, oldTag = null; // Recorded for logging, below.
@@ -468,10 +481,8 @@ export class Alert extends Conversation { // A wrapper around L.marker
468
481
  }
469
482
  }
470
483
 
471
- const cells = getContainingCells(lat, lng);
472
484
  const region = P2PWebNetwork.regionCode(lat, lng);
473
- for (const cell of cells) {
474
- const eventName = alertTopic(cell, hashtag);
485
+ for (const eventName of eventNames) {
475
486
  if (payload) {
476
487
  // The Axona message will be {hashtag, issuedTime, payload:{lat, lng, originalPosting}}
477
488
  // and when combined with the publisher's authorId will be unique to this user/time/hashtag,
@@ -498,6 +509,10 @@ export class Alert extends Conversation { // A wrapper around L.marker
498
509
  this.publishing = false;
499
510
  }
500
511
  }
512
+ static zoomOn(lat, lng, hashtag = '', zoom = map.getZoom() + 1) {
513
+ if (hashtag) showMessage(Int`There are already at least ${this.aggregateLimit} alerts of type "${hashtag}" in the colored region. Zooming in to get a better look.`);
514
+ map.setZoomAround([lat, lng], zoom);
515
+ }
501
516
 
502
517
  static noMessage = Int`No additional information.`;
503
518
  static closePopup() { // Close any open popup.
@@ -547,6 +562,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
547
562
 
548
563
  needsRedisplay = true;
549
564
  ensureContent(popup = this.marker.getPopup()) { // Set content and handlers in popup if/as needed.
565
+ if (!popup) return;
550
566
  if (!popup.isOpen()) return;
551
567
  this.logAlert();
552
568
  if (!this.needsRedisplay) {
@@ -562,8 +578,15 @@ export class Alert extends Conversation { // A wrapper around L.marker
562
578
  delay(100).then(() => {
563
579
  this.marker.getPopup().update();
564
580
  this.initializeHandlers(popup);
581
+ this.teach('firstConversation');
582
+ if (Agent.isMine(this.agent)) this.teach('firstPublish');
565
583
  });
566
584
  }
585
+ teach(classname) {
586
+ if (localStorage.getItem(classname)) return;
587
+ localStorage.setItem(classname, '1');
588
+ document.body.classList.toggle(classname, true);
589
+ }
567
590
  clearAvatars(popup = this.marker?.getPopup()) {
568
591
  popup?.getElement()?.querySelectorAll('.correspondent[data-tag]')
569
592
  .forEach(element => Agent.ensure({tag: element.dataset.tag}).removeElement(element, 'mixed', element.classList.contains('avatar') ? 'avatar' : 'handle'));
@@ -574,6 +597,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
574
597
  const replyButton = replyInput.querySelector('md-filled-icon-button');
575
598
  const replyAttachButton = replyInput.querySelector('md-tonal-icon-button');
576
599
  const fileChooser = popupElement.querySelector('input[type="file"]');
600
+ replyInput.onfocus = event => resetInactivityTimer();
577
601
  replyInput.oninput = event => {
578
602
  replyButton.removeAttribute('disabled');
579
603
  const input = event.currentTarget;
@@ -582,7 +606,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
582
606
  input.rows = internalHighWater;
583
607
  };
584
608
  clickTip(replyButton, Int`Post your reply.`, event => this.postReply(event));
585
- clickTip(replyAttachButton, Int`Attach a file to your reply.`, event => { resetInactivityTimer(); fileChooser.click(); });
609
+ clickTip(replyAttachButton, getText('.teach.attach'), event => { resetInactivityTimer(); fileChooser.click(); });
586
610
  fileChooser.onchange = event => {
587
611
  resetInactivityTimer();
588
612
  replyButton.removeAttribute('disabled');
@@ -597,9 +621,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
597
621
  const isAvatar = correspondent.classList.contains('avatar');
598
622
  if (agent.addElement(correspondent, 'mixed', isAvatar ? 'avatar' : 'handle')) {
599
623
  const isMine = Agent.isMine(tag);
600
- clickTip(correspondent, isMine ?
601
- Int`Control how others see me.` :
602
- Int`Control how this person is labeled on my device.`,
624
+ clickTip(correspondent, getText((isMine ? '.firstPublish' : '.firstConversation') + ' .teach.correspondent'),
603
625
  event => {
604
626
  if (isMine) openAbout(event);
605
627
  else agent.describe(event);
@@ -618,14 +640,14 @@ export class Alert extends Conversation { // A wrapper around L.marker
618
640
  const shareable = popupElement.querySelectorAll('.share');
619
641
  for (const element of shareable) clickTip(element, element.closest('.reply') ?
620
642
  Int`Share though ${osName()} the text and attachments of this reply, with a link to open this alert.` :
621
- Int`Share through ${osName()} a link to open this alert.`, event => this.share(event));
643
+ getText('.teach.share'), event => this.share(event));
622
644
  }
623
645
  initChangeHashtag(someParent) { // Init handler on the menu button, if any, as (re-) init of menu for open popup
624
646
  const changeHashtag = someParent.querySelector('.changeHashtag');
625
647
  if (!changeHashtag) return;
626
648
  const menu = document.getElementById('popoverMenu');
627
649
  menu.anchorElement = changeHashtag;
628
- clickTip(changeHashtag, Int`Change the topic or delete your alert.`, event => {
650
+ clickTip(changeHashtag, getText('.teach.changeHashtag'), event => {
629
651
  consume(event);
630
652
  menu.open = !menu.open;
631
653
  menu.onclick = consume; // Must be onlick rather than addEventListener.
@@ -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
- return this.hashtags[key];
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
- Object.values(Alert.items).forEach(wrapper => this.hashtags[wrapper.hashtag] || wrapper.destroy());
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(/^#/, '') // No leading hash
190
- .replace(/\s+/g, ' ') // Replace multiple spaces with a single space
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
- const li = document.createElement('li');
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) debugLogger(health);
70
+ if (debugLogger) this.debug('health at disconnect', health);
69
71
  else this.shortHealth(health);
70
72
  this.resetStatePromises();
71
73
  }
@@ -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
- ((typeof(subHandler) !== 'function') && (typeof(inFlightResolver) !== 'function')))
102
- console.warn('no handler or request', {tag, rest, subHandler, inFlightResolver, handlers, inFlight});
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];
@@ -69,7 +69,7 @@ async function pauseInvoke(...rest) {
69
69
  await new Promise(resolve => setTimeout(resolve, throttleMS));
70
70
  }
71
71
 
72
- export async function subscribe(topicName, nodeTag, {since = 'all'}) {
72
+ export function subscribe(topicName, nodeTag, {since = 'all'}) {
73
73
  // Axona allows multiple handlers on the same topic, but we don't use that in civildefense, and do not implement it here.
74
74
  const topicId = deriveTopicId(topicName);
75
75
  const id = uuidv4();
@@ -77,24 +77,25 @@ export async function subscribe(topicName, nodeTag, {since = 'all'}) {
77
77
  expire('sub', topicId, id, () => deleteSub(topicId, id), SUBSCRIPTION_TIMEOUT);
78
78
  setBucket(data, 'sub', topicId, nodeTag, id);
79
79
  if (since) { // invoke handler on any sticky data, but only after we have told client the subscription id.
80
- await delay(100);
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;
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);
91
95
  }
92
- break;
93
- default: // Must be a timestamp
94
- if (envelope.ts === since) await pauseInvoke(nodeTag, id, envelope);
95
96
  }
96
- }
97
- if (lastEnvelope) invoke(nodeTag, id, lastEnvelope);
97
+ if (lastEnvelope) invoke(nodeTag, id, lastEnvelope);
98
+ }, 100);
98
99
  }
99
100
  return {topicName, topicId, id};
100
101
  }
@@ -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,8 +25,11 @@ 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 topicTag(topicName) { // Return the hashtag.
29
+ return topicName.slice(topicName.lastIndexOf(':') + 1); // Currently, colon is not allowed in hashtags.
30
+ }
28
31
  export function topicCellHex(topicName) { // Return the hex string cell that is baked in to the topic.
29
- // Note that canonicalTag(tag) may contain a colon, but dataVersion must not.
32
+ // dataVersion cannot contain a colon.
30
33
  return topicName.match(/civildefense.io:[^:]+:([a-z0-9]+):/)[1];
31
34
  }
32
35
  export function topicCell(topicName) { // Return the BigInt cell that is baked in to the topic.
@@ -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.23';
3
+ const serviceVersion = '4.5.28';
4
4
 
5
5
  const cacheList = [ // The files we need.
6
6
  "/",
@@ -129,7 +129,8 @@ async function cacheFirst({request, event}) {
129
129
  // Put clone of response in cache (so that original can be returned.
130
130
  // Tell event to keep worker open while we put it, even though we return response immediately.
131
131
  const cache = await caches.open(serviceVersion);
132
- event.waitUntil(cache.put(request, responseFromNetwork.clone()));
132
+ event.waitUntil(cache.put(request, responseFromNetwork.clone())
133
+ .catch(error => console.error(request.url, mime, error)));
133
134
  return responseFromNetwork;
134
135
  } catch (error) {
135
136
  console.error(request.url, error);
@@ -273,16 +273,16 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
273
273
  text-align: center;
274
274
  }
275
275
  .aggregate.alert-pin {
276
- font-size: 60px;
277
- line-height: 60px;
278
- top: -10px;
279
- left: -10px;
276
+ font-size: 40px;
277
+ line-height: 40px;
278
+ top: 0px;
279
+ left: 0px;
280
280
  transition: font-size 0.8s;
281
281
  }
282
- .aggregate.alert-pin:after {
282
+ .aggregate.alert-pin:before {
283
283
  content: "";
284
- width: 70px;
285
- height: 70px;
284
+ width: 50px;
285
+ height: 50px;
286
286
  position: absolute;
287
287
  top: -5px;
288
288
  left: -5px;
@@ -434,9 +434,93 @@ md-menu {
434
434
  display: flex;
435
435
  align-items: center;
436
436
  }
437
+
438
+ .teach {
439
+ position: absolute;
440
+ z-index: 1000;
441
+ background: linear-gradient(color-mix(in srgb, var(--md-sys-color-primary), white 40%), var(--md-sys-color-primary));
442
+ color: white;
443
+ padding: 10px;
444
+ border-radius: 12px;
445
+ }
446
+ .changeHashtag {
447
+ anchor-name: --changeHashtag;
448
+ }
449
+ .correspondent.avatar {
450
+ anchor-name: --correspondentAvatar;
451
+ }
452
+ .attribution > .share {
453
+ anchor-name: --share;
454
+ }
455
+ .reply-input [slot="leading-icon"] {
456
+ anchor-name: --attach;
457
+ }
458
+ md-filter-chip[label="🆘 help"], md-filter-chip[label="🆘 ayuda"] {
459
+ anchor-name: --topicToggle;
460
+ }
461
+ .leaflet-popup-content-wrapper {
462
+ anchor-name: --popup;
463
+ }
464
+ body.firstPublish.firstConversation .firstConversation .correspondent,
465
+ body:not(.firstPublish) .firstPublish,
466
+ body:not(.firstConversation) .firstConversation {
467
+ display: none;
468
+ }
469
+ .teach.share {
470
+ position-anchor: --share;
471
+ bottom: calc(anchor(top) + 60px);
472
+ left: anchor(--popup left);
473
+ max-width: min(45vw, 310px);
474
+ }
475
+ .teach.correspondent {
476
+ position-anchor: --correspondentAvatar;
477
+ bottom: calc(anchor(top) + 15px);
478
+ left: anchor(left);
479
+ }
480
+ .teach.changeHashtag {
481
+ position-anchor: --changeHashtag;
482
+ bottom: calc(anchor(top) + 60px);
483
+ right: anchor(--popup right);
484
+ width: min(45vw, 310px);
485
+ }
486
+ .teach.attach {
487
+ position-anchor: --attach;
488
+ top: calc(anchor(bottom) + 15px);
489
+ left: anchor(--popup left);
490
+ background: linear-gradient(var(--md-sys-color-primary), color-mix(in srgb, var(--md-sys-color-primary), white 40%));
491
+ }
492
+ .teach:after {
493
+ content: "";
494
+ position: absolute;
495
+ display: block;
496
+ width: 0;
497
+ border-style: solid;
498
+ border-color: var(--md-sys-color-primary) transparent;
499
+ }
500
+ .teach.share:after {
501
+ bottom: -60px;
502
+ left: 30px;
503
+ border-width: 60px 15px 0;
504
+ }
505
+ .teach.correspondent:after {
506
+ bottom: -13px;
507
+ left: 6px;
508
+ border-width: 15px 15px 0;
509
+ }
510
+ .teach.changeHashtag:after {
511
+ bottom: -60px;
512
+ right: 45px;
513
+ border-width: 60px 15px 0;
514
+ }
515
+ .teach.attach:after {
516
+ top: -15px;
517
+ left: 32px;
518
+ border-width: 0 15px 15px;
519
+ }
437
520
  md-outlined-button {
438
521
  padding: 10px;
439
522
  }
523
+
440
524
  /* .attribution > div:last-child > span { */
441
525
  /* text-wrap: nowrap; */
442
526
  /* } */
@@ -1,4 +1,4 @@
1
- {
1
+ {
2
2
  "uswest/80": "37,-122",
3
3
  "useast/89": "40,-75",
4
4
  "uscentle/88": "35,-83",
@@ -5,7 +5,7 @@ import * as operator from '../public/javascripts/pubsub.js';
5
5
  const sockets = {};
6
6
  operator.setReceiver((nodeId, id, envelope) => {
7
7
  const socket = sockets[nodeId];
8
- socket.send(JSON.stringify([id, envelope]));
8
+ socket?.send(JSON.stringify([id, envelope]));
9
9
  });
10
10
 
11
11
  function heartbeat() {
@@ -22,7 +22,7 @@ export function configureWebsocket(server) {
22
22
  ws.on('message', async message => {
23
23
  const [id, methodName, ...rest] = JSON.parse(message);
24
24
  const result = await operator[methodName](...rest);
25
- ws.send(JSON.stringify([id, result]));
25
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify([id, result]));
26
26
  });
27
27
 
28
28
  ws.isAlive = true;
@@ -39,8 +39,10 @@ export function configureWebsocket(server) {
39
39
  const interval = setInterval(function ping() { // Keep-alive ping/pong on interval
40
40
  wss.clients.forEach(function each(ws) {
41
41
  if (!ws.isAlive) return ws.terminate();
42
- ws.isAlive = false;
43
- ws.ping();
42
+ if (ws.readyState == WebSocket.OPEN) {
43
+ ws.isAlive = false;
44
+ ws.ping();
45
+ }
44
46
  return null;
45
47
  });
46
48
  }, 20e3);