@yz-social/civildefense.io 4.5.7 → 4.5.9
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 +2 -2
- package/public/about/en.html +1 -1
- package/public/about/es.html +1 -1
- package/public/index.html +102 -102
- package/public/javascripts/alert.js +7 -6
- package/public/javascripts/conversation.js +8 -5
- package/public/javascripts/hashtags.js +188 -21
- package/public/javascripts/map.js +4 -2
- package/public/javascripts/p2pWebNetwork.js +8 -0
- package/public/junk.html +145 -0
- package/public/material-combobox.html +379 -0
- package/public/service-worker.js +1 -1
- package/public/stylesheets/style.css +69 -8
- package/server/app.js +19 -5
- package/server/location.json +7 -0
- package/spec/conversationSpec.js +2 -2
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.
|
|
4
|
+
"version": "4.5.9",
|
|
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#semver:^4.
|
|
20
|
+
"@axona/protocol": "github:axona-net/axona-protocol#semver:^4.48.0",
|
|
21
21
|
"cors": "^2.8.6",
|
|
22
22
|
"express": "^4.22.1",
|
|
23
23
|
"leaflet": "^1.9.4",
|
package/public/about/en.html
CHANGED
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
<!-- <hr class="rule"> -->
|
|
129
129
|
|
|
130
130
|
<h1>The Bigger Picture</h1>
|
|
131
|
-
<p>The Civil Defense
|
|
131
|
+
<p>The Civil Defense runs on the <a href="https://github.com/axona-net/axona-protocol" target="yz.other" rel="noopener">Axona network</a>. Both are free and open source.</p>
|
|
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
|
|
package/public/about/es.html
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
</ul>
|
|
114
114
|
|
|
115
115
|
<h1>El Panorama General</h1>
|
|
116
|
-
<p>
|
|
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
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
|
|
package/public/index.html
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@material/web/": "https://esm.run/@material/web/",
|
|
27
27
|
"s2js": "./s2js/s2js.esm.js",
|
|
28
28
|
"bigfloat": "./bigfloat/esm/index.js",
|
|
29
|
-
|
|
29
|
+
"leaflet": "./leaflet/leaflet-src.esm.js",
|
|
30
30
|
"minidenticons": "./minidenticons/minidenticons.min.js",
|
|
31
31
|
"@axona/protocol": "./axona-protocol/src/index.js",
|
|
32
32
|
"@axona/protocol/std": "./axona-protocol/std/index.js",
|
|
@@ -45,123 +45,123 @@
|
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<section data-nosnippet>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<div id="subPopoverControls"> <!-- Our control, that appear within the map and the marker popups in stack order -->
|
|
48
|
+
<div id="mapCapture">
|
|
49
|
+
<div id="map"></div>
|
|
50
|
+
<img src="images/civil-defense-240.png" alt="civildefense" class="about-image">
|
|
51
|
+
</div>
|
|
54
52
|
|
|
55
|
-
<
|
|
56
|
-
<img src="images/civil-defense-240.png" alt="civildefense" class="about-image"></img>
|
|
57
|
-
</button>
|
|
53
|
+
<div id="subPopoverControls"> <!-- Our control, that appear within the map and the marker popups in stack order -->
|
|
58
54
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<img src="images/qr-scan.svg" alt="qr code" class="button-image">
|
|
62
|
-
</button>
|
|
63
|
-
<button id="share">
|
|
64
|
-
<img src="images/share.svg" alt="share" class="button-image">
|
|
55
|
+
<button id="aboutButton" class="about-button">
|
|
56
|
+
<img src="images/civil-defense-240.png" alt="civildefense" class="about-image"></img>
|
|
65
57
|
</button>
|
|
66
|
-
<button id="recenterButton">
|
|
67
|
-
<img src="images/recenter.svg" alt="recenter on position" class="button-image">
|
|
68
|
-
</button>
|
|
69
|
-
</div>
|
|
70
58
|
|
|
71
|
-
|
|
72
|
-
|
|
59
|
+
<div class="button-container">
|
|
60
|
+
<button id="qrButton">
|
|
61
|
+
<img src="images/qr-scan.svg" alt="qr code" class="button-image">
|
|
62
|
+
</button>
|
|
63
|
+
<button id="share">
|
|
64
|
+
<img src="images/share.svg" alt="share" class="button-image">
|
|
65
|
+
</button>
|
|
66
|
+
<button id="recenterButton">
|
|
67
|
+
<img src="images/recenter.svg" alt="recenter on position" class="button-image">
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
73
70
|
|
|
74
|
-
|
|
71
|
+
<md-chip-set class="watching-hashtags"></md-chip-set>
|
|
72
|
+
</div>
|
|
75
73
|
|
|
76
|
-
|
|
77
|
-
<div class="display-content"></div>
|
|
78
|
-
</div>
|
|
74
|
+
<div id="info" class="info-banner loading"></div>
|
|
79
75
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<h1>Civil Defense</h1>
|
|
83
|
-
<ul>
|
|
84
|
-
<li id="aboutReport"></li>
|
|
85
|
-
<li id="aboutShared"></li>
|
|
86
|
-
<li id="aboutFade"></li>
|
|
87
|
-
</ul>
|
|
88
|
-
<p> <a id="learnMore" href="about/index.html" target="yz.sidebar">Learn More</a> </p>
|
|
89
|
-
<p>
|
|
90
|
-
<span id="aboutAnyone1"></span>
|
|
91
|
-
<a id="aboutAnyone2" href="https://github.com/YZ-social/Yz.social" target="yz.sidebar"></a>
|
|
92
|
-
<span id="aboutAnyone3"></span>
|
|
93
|
-
</p>
|
|
94
|
-
<hr>
|
|
95
|
-
<div>
|
|
96
|
-
<label>
|
|
97
|
-
<md-checkbox id="showNotifications" touch-target="wrapper"></md-checkbox>
|
|
98
|
-
<span id="showNotificationsLabel"></span>
|
|
99
|
-
</label>
|
|
100
|
-
</div>
|
|
101
|
-
<div class="handle-avatar">
|
|
102
|
-
<md-outlined-text-field id="myHandle" placeholder="anonymous"></md-outlined-text-field>
|
|
103
|
-
<md-outlined-icon-button id="myAvatar"></md-outlined-icon-button>
|
|
104
|
-
</div>
|
|
105
|
-
<hr>
|
|
106
|
-
<div class="source-updates">
|
|
107
|
-
<md-elevated-button id="checkForUpdates"></md-elevated-button>
|
|
108
|
-
<md-elevated-button id="downloadUpdates" class="hidden"></md-elevated-button>
|
|
109
|
-
<span id="updateStatus"></span>
|
|
110
|
-
</div>
|
|
111
|
-
<p><small>
|
|
112
|
-
<label for="scriptChooser"> <span id="version">Version</span></label>
|
|
113
|
-
<span id="appVersion"></span>,
|
|
114
|
-
kernel: <span id="kernelVersion"></span>.
|
|
115
|
-
</small>
|
|
116
|
-
<small><a id="wipe" href="#"></a></small></p>
|
|
117
|
-
<input id="scriptChooser" type="file" accept="text/javascript"></input>
|
|
76
|
+
<div id="qrContainer" class="hidden">
|
|
77
|
+
<div class="display-content"></div>
|
|
118
78
|
</div>
|
|
119
|
-
</div>
|
|
120
79
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
80
|
+
<div id="aboutContainer" class="hidden">
|
|
81
|
+
<div class="display-content">
|
|
82
|
+
<h1>Civil Defense</h1>
|
|
83
|
+
<ul>
|
|
84
|
+
<li id="aboutReport"></li>
|
|
85
|
+
<li id="aboutShared"></li>
|
|
86
|
+
<li id="aboutFade"></li>
|
|
87
|
+
</ul>
|
|
88
|
+
<p> <a id="learnMore" href="about/index.html" target="yz.sidebar">Learn More</a> </p>
|
|
89
|
+
<p>
|
|
90
|
+
<span id="aboutAnyone1"></span>
|
|
91
|
+
<a id="aboutAnyone2" href="https://github.com/YZ-social/Yz.social" target="yz.sidebar"></a>
|
|
92
|
+
<span id="aboutAnyone3"></span>
|
|
93
|
+
</p>
|
|
94
|
+
<hr>
|
|
95
|
+
<div>
|
|
96
|
+
<label>
|
|
97
|
+
<md-checkbox id="showNotifications" touch-target="wrapper"></md-checkbox>
|
|
98
|
+
<span id="showNotificationsLabel"></span>
|
|
99
|
+
</label>
|
|
129
100
|
</div>
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
101
|
+
<div class="handle-avatar">
|
|
102
|
+
<md-outlined-text-field id="myHandle" placeholder="anonymous"></md-outlined-text-field>
|
|
103
|
+
<md-outlined-icon-button id="myAvatar"></md-outlined-icon-button>
|
|
104
|
+
</div>
|
|
105
|
+
<hr>
|
|
106
|
+
<div class="source-updates">
|
|
107
|
+
<md-elevated-button id="checkForUpdates"></md-elevated-button>
|
|
108
|
+
<md-elevated-button id="downloadUpdates" class="hidden"></md-elevated-button>
|
|
109
|
+
<span id="updateStatus"></span>
|
|
110
|
+
</div>
|
|
111
|
+
<p><small>
|
|
112
|
+
<label for="scriptChooser"> <span id="version">Version</span></label>
|
|
113
|
+
<span id="appVersion"></span>,
|
|
114
|
+
kernel: <span id="kernelVersion"></span>.
|
|
115
|
+
</small>
|
|
116
|
+
<small><a id="wipe" href="#"></a></small></p>
|
|
117
|
+
<input id="scriptChooser" type="file" accept="text/javascript"></input>
|
|
143
118
|
</div>
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div id="correspondentContainer" class="hidden">
|
|
122
|
+
<div class="display-content">
|
|
123
|
+
<div class="private-label">
|
|
124
|
+
<div class="describe">
|
|
125
|
+
<span id="describePrivate1"></span>
|
|
126
|
+
<span class="handle"></span>
|
|
127
|
+
<md-icon class="avatar"></md-icon>
|
|
128
|
+
<span id="describePrivate2"></span>
|
|
129
|
+
</div>
|
|
130
|
+
<md-outlined-text-field></md-outlined-text-field>
|
|
131
|
+
<md-outlined-icon-button></md-outlined-icon-button>
|
|
132
|
+
</div>
|
|
133
|
+
<div id="pickLabels"></div>
|
|
134
|
+
<div class="public-label">
|
|
135
|
+
<div id="describePublic" class="describe"></div>
|
|
136
|
+
<md-outlined-button></md-outlined-button>
|
|
137
|
+
<md-outlined-icon-button></md-outlined-icon-button>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="system-label">
|
|
140
|
+
<div id="describeSystem" class="describe"></div>
|
|
141
|
+
<md-outlined-button></md-outlined-button>
|
|
142
|
+
<md-outlined-icon-button></md-outlined-icon-button>
|
|
143
|
+
</div>
|
|
144
|
+
<input type="file" accept="image/*"></input>
|
|
145
|
+
<div>
|
|
146
|
+
<md-text-button id="correspondentCancel">Cancel</md-text-button>
|
|
147
|
+
<md-text-button id="correspondentOK">OK</md-text-button>
|
|
148
|
+
</div>
|
|
148
149
|
</div>
|
|
149
150
|
</div>
|
|
150
|
-
</div>
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
152
|
+
<div id="updateContainer" class="hidden">
|
|
153
|
+
<div class="display-content">
|
|
154
|
+
<h1 id="newVersionHeader">New version available</h1>
|
|
155
|
+
<p id="updateNowQuestion"></p>
|
|
156
|
+
<p id="updateReload"></p>
|
|
157
|
+
<p id="updateDefer"></p>
|
|
158
|
+
<md-elevated-button id="downloadUpdates2"></md-elevated-button>
|
|
159
|
+
<md-elevated-button id="downloadDefer"></md-elevated-button>
|
|
160
|
+
</div>
|
|
160
161
|
</div>
|
|
161
|
-
</div>
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
<script type="module" src="javascripts/main.js"></script>
|
|
163
|
+
<md-menu positioning="popover" id="popoverMenu"></md-menu>
|
|
165
164
|
</section>
|
|
165
|
+
<script type="module" src="javascripts/main.js"></script>
|
|
166
166
|
</body>
|
|
167
167
|
</html>
|
|
@@ -42,7 +42,7 @@ export async function share(properties) { // Invoke platform share API on prope
|
|
|
42
42
|
if (!properties.files) {
|
|
43
43
|
Alert.closePopup();
|
|
44
44
|
await delay(500); // Allow popup time to close. It doesn't render well because of the web component style sheets.
|
|
45
|
-
const target = document.getElementById('mapCapture');
|
|
45
|
+
const target = document.getElementById('mapCapture'); // For capturing a screen shot.
|
|
46
46
|
const icon = target.lastElementChild;
|
|
47
47
|
const subPopoverControls = document.getElementById('subPopoverControls');
|
|
48
48
|
const leafletControls = document.querySelector('.leaflet-control-container');
|
|
@@ -85,7 +85,7 @@ class AlertReply extends Reply {
|
|
|
85
85
|
// a promise to reassemble the attachment, have formatReply create a findable placeholder to later replace with the A element
|
|
86
86
|
// and contents, and then have ensureContent() arrange in its delayed followup to replace the placeholder when the promise
|
|
87
87
|
// resolves.
|
|
88
|
-
super.initialize(properties);
|
|
88
|
+
await super.initialize(properties);
|
|
89
89
|
const {container, agent, issuedTime, payload} = properties;
|
|
90
90
|
const {file:attachmentTopic} = payload;
|
|
91
91
|
if (attachmentTopic) {
|
|
@@ -127,7 +127,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
127
127
|
const newCells = findCoverCellsByCenterAndPoint(center.lat, center.lng, northEast.lat, northEast.lng); // array of cell IDs (BigInts)
|
|
128
128
|
region = P2PWebNetwork.regionCode(center.lat, center.lng);
|
|
129
129
|
newKeys = newCells.flatMap(cell => Hashtags.getSubscribe().map(hash => alertTopic(cell, hash)));
|
|
130
|
-
Agent.current
|
|
130
|
+
Agent.current?.trackPublicChanges(region);
|
|
131
131
|
// Record a zoomed-out cell id in case next session does not have geolocation services.
|
|
132
132
|
let level9Cell = getContainingCells(center.lat, center.lng)[9];
|
|
133
133
|
if (level9Cell !== lastLevel9Cell) localStorage.setItem('level9Cell', lastLevel9Cell = level9Cell);
|
|
@@ -221,6 +221,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
221
221
|
static noMessage = Int`No additional information.`;
|
|
222
222
|
static closePopup() { // Close any open popup.
|
|
223
223
|
map.closePopup();
|
|
224
|
+
Hashtags.closeSelector();
|
|
224
225
|
}
|
|
225
226
|
static openPopup(alertTag) { // Open the marker specified by subject.
|
|
226
227
|
const wrapper = this.getItem(alertTag);
|
|
@@ -235,7 +236,7 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
235
236
|
}
|
|
236
237
|
this.marker.openPopup();
|
|
237
238
|
}
|
|
238
|
-
static makeIcon(hashtag) { // Return a Leaflet icon
|
|
239
|
+
static makeIcon(hashtag) { // Return a Leaflet icon. TODO: are these cacheable and reusable?
|
|
239
240
|
return L.divIcon({
|
|
240
241
|
html: `<div class="alert-commented"></div><div class="alert-pin">${Hashtags.formatAlert(hashtag)}</div>`,
|
|
241
242
|
iconSize: [40, 40],
|
|
@@ -472,12 +473,12 @@ export class Alert extends Conversation { // A wrapper around L.marker
|
|
|
472
473
|
}
|
|
473
474
|
deleteReply(replyElement) {
|
|
474
475
|
resetInactivityTimer();
|
|
475
|
-
const {lat, lng} = this;
|
|
476
|
+
const {lat, lng, subject} = this;
|
|
476
477
|
const region = P2PWebNetwork.regionCode(lat, lng);
|
|
477
478
|
const killTag = replyElement.dataset.subject;
|
|
478
479
|
networkPromise.then(async contact => {
|
|
479
480
|
// We won't be here unless we are the signer.
|
|
480
|
-
await contact.publish({eventName:
|
|
481
|
+
await contact.publish({eventName: subject, region, killTag, payload: null});
|
|
481
482
|
// IFF there's an attachment AND we're given msgIds by receiveChunkedBytes, then delete the attachment.
|
|
482
483
|
const reply = this.getItem(killTag);
|
|
483
484
|
const {attachmentTopic, msgIds = []} = reply?.payload || {};
|
|
@@ -26,13 +26,14 @@ export class Tagged { // Maintains cached existence within a (possibly instance-
|
|
|
26
26
|
destroy() { // Subclasses extend to remove UI.
|
|
27
27
|
// Subclass extensions of ensure may expect this answer falsy, indicating that an item was removed.
|
|
28
28
|
// NOTE: Does not destroy replies, as these may have been published by others.
|
|
29
|
-
|
|
29
|
+
this.container.removeItem(this.tag);
|
|
30
|
+
return null;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
static async ensureIn(data, container, kind = container.itemKind) { // update() or initialize() item and remember what those answer. (Falsy is deleted).
|
|
33
34
|
const {tag, payload, ...rest} = data;
|
|
34
35
|
let item = container.getItem(tag);
|
|
35
|
-
if (!payload) return item
|
|
36
|
+
if (!payload) return item?.destroy();
|
|
36
37
|
if (item) item = await item.update(data);
|
|
37
38
|
else item = await (new kind().initialize({...data, container}));
|
|
38
39
|
|
|
@@ -59,8 +60,10 @@ export class Conversation extends Tagged { // A conversation with replies.
|
|
|
59
60
|
return item;
|
|
60
61
|
}
|
|
61
62
|
removeItem(tag) { // Remove reply from cache.
|
|
62
|
-
const { items } = this;
|
|
63
|
-
|
|
63
|
+
const { items } = this;
|
|
64
|
+
const index = items.findIndex(reply => reply.tag === tag);
|
|
65
|
+
if (index < 0) return null;
|
|
66
|
+
return items.splice(index, 1)?.[0];
|
|
64
67
|
}
|
|
65
68
|
get itemKind() { // Answer class of reply items.
|
|
66
69
|
return Reply;
|
|
@@ -76,7 +79,7 @@ export class Conversation extends Tagged { // A conversation with replies.
|
|
|
76
79
|
if (!Object.hasOwn(this, '_conversations')) this._conversations = {};
|
|
77
80
|
return this._conversations;
|
|
78
81
|
}
|
|
79
|
-
static items() {
|
|
82
|
+
static get items() {
|
|
80
83
|
return Object.values(this.conversations);
|
|
81
84
|
}
|
|
82
85
|
static getItem(tag) { // Get conversation if known, else falsy.
|
|
@@ -47,6 +47,11 @@ export const Hashtags = {
|
|
|
47
47
|
this.onchange({resetSubscriptions: false});
|
|
48
48
|
if (updateAlerts) Alert.updateAlerts(canonical, extended);
|
|
49
49
|
}
|
|
50
|
+
if (!allKnownHashtags.includes(extended)) {
|
|
51
|
+
allKnownHashtags.push(extended);
|
|
52
|
+
this.sort(allKnownHashtags);
|
|
53
|
+
localStorage.setItem('allKnownHashtags', JSON.stringify(allKnownHashtags));
|
|
54
|
+
}
|
|
50
55
|
return extended;
|
|
51
56
|
},
|
|
52
57
|
getAll() { // List of all the user's hashtags.
|
|
@@ -74,9 +79,9 @@ export const Hashtags = {
|
|
|
74
79
|
formatAlert(tag) { // HTML (possibly text) to represent tag as a marker on map.
|
|
75
80
|
return this.firstEmoji(tag) || this.identicon(tag);
|
|
76
81
|
},
|
|
77
|
-
formatPubtag(tag) { // HTML (possibly text) to represent tag with defaulted icon.
|
|
82
|
+
formatPubtag(tag, identiconData = tag) { // HTML (possibly text) to represent tag with defaulted icon.
|
|
78
83
|
const emoji = this.firstEmoji(tag);
|
|
79
|
-
return emoji ? tag : this.identicon(
|
|
84
|
+
return emoji ? tag : this.identicon(identiconData) + tag;
|
|
80
85
|
},
|
|
81
86
|
onchange({redisplaySubscribers = true, highlightPublish = false, resetSubscriptions = true} = {}) { // Update and persist internal data, and update visuals.
|
|
82
87
|
// If redisplaySubscribers, the presence/order may have changed.
|
|
@@ -97,12 +102,116 @@ export const Hashtags = {
|
|
|
97
102
|
<md-icon-button slot="remove-trailing-icon" title="fixme help"><md-icon class="material-icons"></md-icon></md-icon-button>
|
|
98
103
|
</md-filter-chip>`;
|
|
99
104
|
},
|
|
105
|
+
closeSelector() { // Close the autocomplete tag selector
|
|
106
|
+
const { listbox, newtag } = this;
|
|
107
|
+
listbox.classList.toggle('hidden', true);
|
|
108
|
+
newtag.setAttribute('aria-expanded', 'false');
|
|
109
|
+
newtag.removeAttribute('aria-activedescendant');
|
|
110
|
+
newtag.value = '';
|
|
111
|
+
this.activeIndex = -1;
|
|
112
|
+
},
|
|
113
|
+
openSelector() { // Open the autocomplete tag selector
|
|
114
|
+
const { listbox, newtag } = this;
|
|
115
|
+
listbox.classList.toggle('hidden', false);
|
|
116
|
+
newtag?.setAttribute('aria-expanded', 'true');
|
|
117
|
+
},
|
|
118
|
+
setActive(index) {
|
|
119
|
+
const { listbox, newtag } = this;
|
|
120
|
+
this.activeIndex = index;
|
|
121
|
+
listbox.querySelectorAll('.combobox-option').forEach((option, optionIndex) => {
|
|
122
|
+
const active = index === optionIndex;
|
|
123
|
+
option.classList.toggle('active', active);
|
|
124
|
+
if (active) {
|
|
125
|
+
option.scrollIntoView({ block: 'nearest' });
|
|
126
|
+
newtag.setAttribute('aria-activedescendant', option.id);
|
|
127
|
+
} else {
|
|
128
|
+
newtag.removeAttribute('aria-activedescendant');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
selectValue(value) {
|
|
133
|
+
this.newtag.value = value;
|
|
134
|
+
this.acceptTag();
|
|
135
|
+
},
|
|
136
|
+
acceptTag() { // Add the new hashtag.
|
|
137
|
+
resetInactivityTimer();
|
|
138
|
+
let tag = this.newtag?.value.trim() // Get into standard form, but do not strip emoji or case into canonical yet.
|
|
139
|
+
.replace(/^#/, '') // No leading hash
|
|
140
|
+
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
|
|
141
|
+
.normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
|
|
142
|
+
Alert.closePopup();
|
|
143
|
+
if (!tag) return;
|
|
144
|
+
tag = this.add(tag); // Might exist, in which case tag might now be extended.
|
|
145
|
+
this.setPublish(tag);
|
|
146
|
+
this.onchange({highlightPublish: true});
|
|
147
|
+
},
|
|
148
|
+
activeIndex: -1,
|
|
149
|
+
selectors: [],
|
|
150
|
+
renderSelector(query) { // Render the autocompletion of query string.
|
|
151
|
+
const { listbox, newtag } = this;
|
|
152
|
+
function escapeRegExp(str) {
|
|
153
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
154
|
+
}
|
|
155
|
+
function highlight(text, query) {
|
|
156
|
+
if (!query) return text;
|
|
157
|
+
const re = new RegExp(`(${escapeRegExp(query)})`, 'ig');
|
|
158
|
+
return text.replace(re, '<mark>$1</mark>');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// TODO: create the elements only when allKnownHashtags changes. Then hide/highlight here.
|
|
162
|
+
listbox.innerHTML = '';
|
|
163
|
+
this.selectors = allKnownHashtags.filter(item =>
|
|
164
|
+
item.toLowerCase().includes(query.toLowerCase())
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (this.selectors.length === 0) {
|
|
168
|
+
const li = document.createElement('li');
|
|
169
|
+
li.className = 'combobox-empty';
|
|
170
|
+
li.textContent = `Create tag "${query}"`;
|
|
171
|
+
li.onpointerdown = event => {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
event.stopPropagation();
|
|
174
|
+
this.acceptTag();
|
|
175
|
+
};
|
|
176
|
+
listbox.appendChild(li);
|
|
177
|
+
} else {
|
|
178
|
+
this.selectors.forEach((item, i) => {
|
|
179
|
+
const li = document.createElement('li');
|
|
180
|
+
li.className = 'combobox-option';
|
|
181
|
+
li.id = `tag-option-${i}`;
|
|
182
|
+
li.setAttribute('role', 'option');
|
|
183
|
+
li.innerHTML = this.formatPubtag(highlight(item, query), item);
|
|
184
|
+
li.onpointerdown = event => {
|
|
185
|
+
// pointerdown (not click) so it fires before the field's blur event
|
|
186
|
+
event.preventDefault();
|
|
187
|
+
event.stopPropagation();
|
|
188
|
+
this.selectValue(item);
|
|
189
|
+
};
|
|
190
|
+
listbox.appendChild(li);
|
|
191
|
+
});
|
|
192
|
+
setTimeout(() => { // Needs a tick.
|
|
193
|
+
const width = listbox.clientWidth;
|
|
194
|
+
const max = Math.max(125, width);
|
|
195
|
+
newtag.style.width = max + 'px'; // Set newtag so that input box makes room for floating lis
|
|
196
|
+
}, 50);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.openSelector();
|
|
200
|
+
if (this.selectors.length === 1) {
|
|
201
|
+
this.setActive(0);
|
|
202
|
+
} else {
|
|
203
|
+
this.setActive(-1);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
sort(tags) { // Sort list of tags in place without regard to leding emoji
|
|
207
|
+
tags.sort((a, b) => stripLeadingEmoji(a).localeCompare(stripLeadingEmoji(b)));
|
|
208
|
+
},
|
|
100
209
|
resetSubscriberDisplay() { // Lay out all the hashtag chips display, including the input for adding new ones.
|
|
101
210
|
this.chipset.innerHTML = '';
|
|
102
211
|
const tags = this.getAll();
|
|
103
212
|
|
|
104
213
|
// Sort alphabetically, ignoring any leading emoji, as these have unexpected orderings.
|
|
105
|
-
|
|
214
|
+
this.sort(tags);
|
|
106
215
|
const reordered = {};
|
|
107
216
|
tags.forEach(tag => reordered[tag] = this.hashtags[tag]);
|
|
108
217
|
this.hashtags = reordered;
|
|
@@ -139,24 +248,67 @@ export const Hashtags = {
|
|
|
139
248
|
});
|
|
140
249
|
});
|
|
141
250
|
this.chipset.insertAdjacentHTML("afterbegin", // Chip to add a new hashtag.
|
|
142
|
-
`<
|
|
143
|
-
|
|
251
|
+
`<div class="combobox">
|
|
252
|
+
<ul class="combobox-listbox hidden" id="knownTagsListbox" role="listbox"></ul>
|
|
253
|
+
<md-filled-text-field class="newtag"
|
|
254
|
+
aria-expanded="false"
|
|
255
|
+
aria-controls="knownTagsListbox"
|
|
256
|
+
aria-autocomplete="list"
|
|
257
|
+
autocomplete="off"
|
|
258
|
+
tabindex="0"
|
|
259
|
+
placeholder="➕${Int`add topic`}"></md-filled-text-field>
|
|
260
|
+
</div>`);
|
|
261
|
+
// I've tried also supplying a datalist, e.g., to supply the mobile keyboard completions, but
|
|
262
|
+
// I have not been able to get it to work.
|
|
263
|
+
const newtag = this.newtag = this.chipset.querySelector('.newtag');
|
|
264
|
+
const listbox = this.listbox = this.chipset.querySelector('.combobox-listbox');
|
|
265
|
+
clickTip(newtag, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
|
|
144
266
|
event.stopPropagation();
|
|
145
267
|
Alert.closePopup();
|
|
146
|
-
|
|
268
|
+
this.renderSelector('');
|
|
269
|
+
if (navigator.maxTouchPoints <= 1) { // Only when no multi-touch. On-screen keyboard makes it shoot off the top.
|
|
270
|
+
showMessage(Int`Type a new topic name to see any alerts on the map with this topic.`, 'instructions');
|
|
271
|
+
}
|
|
147
272
|
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
273
|
+
newtag.onkeydown = event => {
|
|
274
|
+
if (listbox.classList.contains('hidden')) return;
|
|
275
|
+
const optionCount = listbox.querySelectorAll('.combobox-option').length;
|
|
276
|
+
|
|
277
|
+
switch (event.key) {
|
|
278
|
+
case 'ArrowDown':
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
if (optionCount > 0) this.setActive((this.activeIndex + 1) % optionCount);
|
|
281
|
+
break;
|
|
282
|
+
|
|
283
|
+
case 'ArrowUp':
|
|
284
|
+
event.preventDefault();
|
|
285
|
+
if (optionCount > 0) this.setActive((this.activeIndex - 1 + optionCount) % optionCount);
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
case 'Enter':
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
if (this.activeIndex < 0) {
|
|
291
|
+
this.acceptTag();
|
|
292
|
+
} else {
|
|
293
|
+
this.selectValue(this.selectors[this.activeIndex]);
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
|
|
297
|
+
case 'Escape':
|
|
298
|
+
event.preventDefault();
|
|
299
|
+
this.closeSelector();
|
|
300
|
+
break;
|
|
301
|
+
|
|
302
|
+
case 'Tab':
|
|
303
|
+
event.preventDefault();
|
|
304
|
+
if (this.activeIndex < 0) this.setActive(0); // If nothing active, make the first line active.
|
|
305
|
+
else this.selectValue(this.selectors[this.activeIndex]); // Otherwise select what is active.
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
159
308
|
};
|
|
309
|
+
newtag.oninput = () => this.renderSelector(newtag.value);
|
|
310
|
+
newtag.onblur = () => this.closeSelector();
|
|
311
|
+
newtag.onchange = () => this.acceptTag();
|
|
160
312
|
},
|
|
161
313
|
remove(chip, redisplaySubscribers = false) { // Remove this topic, persistently.
|
|
162
314
|
delete this.hashtags[chip.label];
|
|
@@ -199,8 +351,23 @@ export const Hashtags = {
|
|
|
199
351
|
|
|
200
352
|
// Populate hashtags data and display.
|
|
201
353
|
// First the persisted/default data:
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
354
|
+
const allKnownHashtags = JSON.parse(localStorage.getItem('allKnownHashtags') || `[
|
|
355
|
+
"🍰 cake",
|
|
356
|
+
"🎸 classic rock",
|
|
357
|
+
"🎼 classical",
|
|
358
|
+
"🩷 community support",
|
|
359
|
+
"🤠 country",
|
|
360
|
+
"🎧 edm",
|
|
361
|
+
"🔥 fire",
|
|
362
|
+
"🌊 flood",
|
|
363
|
+
"🆘 help",
|
|
364
|
+
"🎤 hiphop",
|
|
365
|
+
"🧊 ice",
|
|
366
|
+
"🎷 jazz",
|
|
367
|
+
"🎙️ news",
|
|
368
|
+
"👁️ observer corps",
|
|
369
|
+
"🎵 pop",
|
|
370
|
+
"🧰 utility repairs"
|
|
371
|
+
]`);
|
|
372
|
+
const persisted = JSON.parse(localStorage.getItem('hashtags') || `{"🍰 ${Int`cake`}": true, "🆘 ${Int`help`}": "pub"}`);
|
|
206
373
|
Object.entries(persisted).forEach(([tag, active]) => Hashtags.add(tag, active, false));
|
|
@@ -155,6 +155,8 @@ export function initMap(lat, lng, zoom, positionLabel) { // Set up appropriate z
|
|
|
155
155
|
Alert.openPopup(await Alert.publish({lat, lng}));
|
|
156
156
|
Agent.current.persistPublicMetadata(P2PWebNetwork.regionCode(lat, lng));
|
|
157
157
|
});
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
if (document.querySelector('.leaflet-control-zoom')) { // Not present in mobile
|
|
159
|
+
tooltip('.leaflet-control-zoom-in', Int`Zoom in to show more detail in the map.`);
|
|
160
|
+
tooltip('.leaflet-control-zoom-out', Int`Zoom out to show a larger area in the map.`);
|
|
161
|
+
}
|
|
160
162
|
}
|