@yz-social/civildefense.io 4.5.8 → 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/material-combobox.html +379 -0
- package/public/service-worker.js +1 -1
- package/public/stylesheets/style.css +69 -8
- 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
|
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
|
+
<title>Material Web Autocomplete Combo Box</title>
|
|
7
|
+
|
|
8
|
+
<!-- Material Web components (outlined text field + icon) -->
|
|
9
|
+
<script type="module" src="https://esm.run/@material/web/textfield/outlined-text-field.js"></script>
|
|
10
|
+
<script type="module" src="https://esm.run/@material/web/icon/icon.js"></script>
|
|
11
|
+
|
|
12
|
+
<!-- Roboto + Material Symbols, since Material Web expects these by default -->
|
|
13
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" />
|
|
14
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" />
|
|
15
|
+
|
|
16
|
+
<style>
|
|
17
|
+
:root {
|
|
18
|
+
color-scheme: light;
|
|
19
|
+
font-family: 'Roboto', system-ui, sans-serif;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
html, body {
|
|
23
|
+
height: 100%;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
body {
|
|
27
|
+
margin: 0;
|
|
28
|
+
background: #f6f6f9;
|
|
29
|
+
/* Leave room at the bottom for the fixed field so page content
|
|
30
|
+
never sits underneath it. */
|
|
31
|
+
padding-bottom: 96px;
|
|
32
|
+
box-sizing: border-box;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
main.content {
|
|
36
|
+
max-width: 480px;
|
|
37
|
+
margin: 0 auto;
|
|
38
|
+
padding: 24px 20px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
h1 {
|
|
42
|
+
font-size: 1.1rem;
|
|
43
|
+
font-weight: 500;
|
|
44
|
+
color: #1c1b1f;
|
|
45
|
+
margin: 0 0 4px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
p.hint {
|
|
49
|
+
font-size: 0.85rem;
|
|
50
|
+
color: #49454f;
|
|
51
|
+
margin: 0 0 20px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* The field is pinned to the bottom of the *visual* viewport (see JS). */
|
|
55
|
+
/* position: fixed + bottom: 0 alone anchors to the bottom of the */
|
|
56
|
+
/* layout viewport, which on mobile stays put even when a keyboard */
|
|
57
|
+
/* slides up (some browsers just draw the keyboard on top of it, or */
|
|
58
|
+
/* slide the page instead). To keep the field visible directly above */
|
|
59
|
+
/* the keyboard, JS below tracks window.visualViewport and applies a */
|
|
60
|
+
/* translateY offset equal to the keyboard's height. */
|
|
61
|
+
|
|
62
|
+
.combobox {
|
|
63
|
+
position: fixed;
|
|
64
|
+
left: 0;
|
|
65
|
+
right: 0;
|
|
66
|
+
bottom: 0;
|
|
67
|
+
z-index: 10;
|
|
68
|
+
box-sizing: border-box;
|
|
69
|
+
padding: 12px 20px calc(12px + env(safe-area-inset-bottom));
|
|
70
|
+
background: #f6f6f9;
|
|
71
|
+
box-shadow: 0 -1px 0 rgba(0,0,0,0.08);
|
|
72
|
+
max-width: 480px;
|
|
73
|
+
margin: 0 auto;
|
|
74
|
+
/* set by JS to compensate for the on-screen keyboard */
|
|
75
|
+
transform: translateY(0px);
|
|
76
|
+
will-change: transform;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
md-outlined-text-field {
|
|
80
|
+
width: 100%;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
The suggestion list is positioned independently (fixed, computed in
|
|
85
|
+
JS) rather than nested inside .combobox, so its top edge can be
|
|
86
|
+
clamped to the available space above the field regardless of how
|
|
87
|
+
tall the list's content is.
|
|
88
|
+
*/
|
|
89
|
+
.combobox-listbox {
|
|
90
|
+
position: fixed;
|
|
91
|
+
z-index: 20;
|
|
92
|
+
margin: 0;
|
|
93
|
+
padding: 8px 0;
|
|
94
|
+
list-style: none;
|
|
95
|
+
overflow-y: auto;
|
|
96
|
+
background: #fff;
|
|
97
|
+
border-radius: 4px;
|
|
98
|
+
box-shadow: 0 2px 6px 2px rgba(0,0,0,0.15), 0 1px 2px rgba(0,0,0,0.3);
|
|
99
|
+
display: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.combobox-listbox.open {
|
|
103
|
+
display: block;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.combobox-option {
|
|
107
|
+
padding: 10px 16px;
|
|
108
|
+
font-size: 0.95rem;
|
|
109
|
+
color: #1c1b1f;
|
|
110
|
+
cursor: pointer;
|
|
111
|
+
white-space: nowrap;
|
|
112
|
+
overflow: hidden;
|
|
113
|
+
text-overflow: ellipsis;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.combobox-option mark {
|
|
117
|
+
background: none;
|
|
118
|
+
color: #6750a4;
|
|
119
|
+
font-weight: 500;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.combobox-option:hover,
|
|
123
|
+
.combobox-option.active {
|
|
124
|
+
background: #eaddff;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.combobox-empty {
|
|
128
|
+
padding: 10px 16px;
|
|
129
|
+
font-size: 0.9rem;
|
|
130
|
+
color: #79747e;
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
133
|
+
</head>
|
|
134
|
+
<body>
|
|
135
|
+
|
|
136
|
+
<main class="content">
|
|
137
|
+
<h1>Country picker</h1>
|
|
138
|
+
<p class="hint">
|
|
139
|
+
Start typing a country name. Use ↑ / ↓ to navigate, Enter to select, Esc to close.
|
|
140
|
+
The field stays pinned to the bottom of the screen, and the suggestion list opens
|
|
141
|
+
upward above it — try this on a phone to see it stay clear of the keyboard.
|
|
142
|
+
</p>
|
|
143
|
+
</main>
|
|
144
|
+
|
|
145
|
+
<div class="combobox">
|
|
146
|
+
<md-outlined-text-field
|
|
147
|
+
id="country-input"
|
|
148
|
+
label="Country"
|
|
149
|
+
role="combobox"
|
|
150
|
+
aria-expanded="false"
|
|
151
|
+
aria-controls="country-listbox"
|
|
152
|
+
aria-autocomplete="list"
|
|
153
|
+
autocomplete="off"
|
|
154
|
+
>
|
|
155
|
+
<md-icon slot="leading-icon">search</md-icon>
|
|
156
|
+
</md-outlined-text-field>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<ul class="combobox-listbox" id="country-listbox" role="listbox"></ul>
|
|
160
|
+
|
|
161
|
+
<script type="module">
|
|
162
|
+
// Wait for the custom element to be defined so its internal <input> exists.
|
|
163
|
+
await customElements.whenDefined('md-outlined-text-field');
|
|
164
|
+
|
|
165
|
+
const DATA = [
|
|
166
|
+
"Argentina","Australia","Austria","Bangladesh","Belgium","Brazil","Canada",
|
|
167
|
+
"Chile","China","Colombia","Denmark","Egypt","Finland","France","Germany",
|
|
168
|
+
"Greece","India","Indonesia","Ireland","Italy","Japan","Kenya","Malaysia",
|
|
169
|
+
"Mexico","Netherlands","New Zealand","Nigeria","Norway","Pakistan","Peru",
|
|
170
|
+
"Philippines","Poland","Portugal","Russia","Saudi Arabia","Singapore",
|
|
171
|
+
"South Africa","South Korea","Spain","Sweden","Switzerland","Thailand",
|
|
172
|
+
"Turkey","Ukraine","United Arab Emirates","United Kingdom","United States",
|
|
173
|
+
"Vietnam"
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
const footer = document.querySelector('.combobox');
|
|
177
|
+
const field = document.getElementById('country-input');
|
|
178
|
+
const listbox = document.getElementById('country-listbox');
|
|
179
|
+
|
|
180
|
+
let matches = [];
|
|
181
|
+
let activeIndex = -1;
|
|
182
|
+
|
|
183
|
+
function getValue() {
|
|
184
|
+
return field.value ?? '';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function escapeRegExp(str) {
|
|
188
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function highlight(text, query) {
|
|
192
|
+
if (!query) return text;
|
|
193
|
+
const re = new RegExp(`(${escapeRegExp(query)})`, 'ig');
|
|
194
|
+
return text.replace(re, '<mark>$1</mark>');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------
|
|
198
|
+
// Keyboard-aware positioning
|
|
199
|
+
// ---------------------------------------------------------------------
|
|
200
|
+
// window.visualViewport reports the *actually visible* area, shrinking
|
|
201
|
+
// when a mobile keyboard appears. window.innerHeight / layout viewport
|
|
202
|
+
// rects do not shrink the same way, which is why a plain
|
|
203
|
+
// `position: fixed; bottom: 0` element can end up hidden behind the
|
|
204
|
+
// keyboard. We measure the gap between the two and shift the field up
|
|
205
|
+
// by exactly that amount.
|
|
206
|
+
function keyboardInset() {
|
|
207
|
+
const vv = window.visualViewport;
|
|
208
|
+
if (!vv) return 0;
|
|
209
|
+
return Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function repositionField() {
|
|
213
|
+
footer.style.transform = `translateY(-${keyboardInset()}px)`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Positions the listbox so its bottom edge sits just above the field,
|
|
217
|
+
// and clamps its top edge (via max-height) to the visible viewport,
|
|
218
|
+
// so it never runs off-screen above a shrunken (keyboard-open) viewport.
|
|
219
|
+
function repositionListbox() {
|
|
220
|
+
const rect = field.getBoundingClientRect();
|
|
221
|
+
const vv = window.visualViewport;
|
|
222
|
+
const viewportTop = vv ? vv.offsetTop : 0;
|
|
223
|
+
const gap = 4;
|
|
224
|
+
const topMargin = 8;
|
|
225
|
+
|
|
226
|
+
listbox.style.left = `${rect.left}px`;
|
|
227
|
+
listbox.style.width = `${rect.width}px`;
|
|
228
|
+
listbox.style.bottom = `${window.innerHeight - rect.top + gap}px`;
|
|
229
|
+
|
|
230
|
+
const available = rect.top - viewportTop - gap - topMargin;
|
|
231
|
+
listbox.style.maxHeight = `${Math.max(80, available)}px`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function repositionAll() {
|
|
235
|
+
repositionField();
|
|
236
|
+
// Wait a frame so the field's transform has applied before we read
|
|
237
|
+
// its position for the listbox.
|
|
238
|
+
requestAnimationFrame(repositionListbox);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
window.visualViewport?.addEventListener('resize', repositionAll);
|
|
242
|
+
window.visualViewport?.addEventListener('scroll', repositionAll);
|
|
243
|
+
window.addEventListener('resize', repositionAll);
|
|
244
|
+
window.addEventListener('orientationchange', repositionAll);
|
|
245
|
+
repositionAll();
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------
|
|
248
|
+
// Combobox behavior
|
|
249
|
+
// ---------------------------------------------------------------------
|
|
250
|
+
function closeListbox() {
|
|
251
|
+
listbox.classList.remove('open');
|
|
252
|
+
field.setAttribute('aria-expanded', 'false');
|
|
253
|
+
field.removeAttribute('aria-activedescendant');
|
|
254
|
+
activeIndex = -1;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function openListbox() {
|
|
258
|
+
listbox.classList.add('open');
|
|
259
|
+
field.setAttribute('aria-expanded', 'true');
|
|
260
|
+
repositionListbox();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function setActive(index) {
|
|
264
|
+
const options = listbox.querySelectorAll('.combobox-option');
|
|
265
|
+
options.forEach(opt => opt.classList.remove('active'));
|
|
266
|
+
activeIndex = index;
|
|
267
|
+
if (index >= 0 && options[index]) {
|
|
268
|
+
options[index].classList.add('active');
|
|
269
|
+
options[index].scrollIntoView({ block: 'nearest' });
|
|
270
|
+
field.setAttribute('aria-activedescendant', options[index].id);
|
|
271
|
+
} else {
|
|
272
|
+
field.removeAttribute('aria-activedescendant');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function selectValue(value) {
|
|
277
|
+
field.value = value;
|
|
278
|
+
closeListbox();
|
|
279
|
+
field.focus();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function renderMatches(query) {
|
|
283
|
+
listbox.innerHTML = '';
|
|
284
|
+
|
|
285
|
+
if (!query) {
|
|
286
|
+
closeListbox();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
matches = DATA.filter(item =>
|
|
291
|
+
item.toLowerCase().includes(query.toLowerCase())
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
if (matches.length === 0) {
|
|
295
|
+
const li = document.createElement('li');
|
|
296
|
+
li.className = 'combobox-empty';
|
|
297
|
+
li.textContent = 'No matches found';
|
|
298
|
+
listbox.appendChild(li);
|
|
299
|
+
openListbox();
|
|
300
|
+
setActive(-1);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
matches.forEach((item, i) => {
|
|
305
|
+
const li = document.createElement('li');
|
|
306
|
+
li.className = 'combobox-option';
|
|
307
|
+
li.id = `country-option-${i}`;
|
|
308
|
+
li.setAttribute('role', 'option');
|
|
309
|
+
li.innerHTML = highlight(item, query);
|
|
310
|
+
li.addEventListener('mousedown', (e) => {
|
|
311
|
+
// mousedown (not click) so it fires before the field's blur event
|
|
312
|
+
e.preventDefault();
|
|
313
|
+
selectValue(item);
|
|
314
|
+
});
|
|
315
|
+
listbox.appendChild(li);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
openListbox();
|
|
319
|
+
setActive(-1);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
field.addEventListener('input', () => {
|
|
323
|
+
renderMatches(getValue());
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// The keyboard opening/closing changes visualViewport size, which
|
|
327
|
+
// fires our resize listener above and keeps everything positioned
|
|
328
|
+
// correctly without any extra work here.
|
|
329
|
+
field.addEventListener('focus', () => {
|
|
330
|
+
if (getValue()) renderMatches(getValue());
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
field.addEventListener('keydown', (e) => {
|
|
334
|
+
const isOpen = listbox.classList.contains('open');
|
|
335
|
+
const optionCount = listbox.querySelectorAll('.combobox-option').length;
|
|
336
|
+
|
|
337
|
+
switch (e.key) {
|
|
338
|
+
case 'ArrowDown':
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
if (!isOpen) { renderMatches(getValue()); return; }
|
|
341
|
+
if (optionCount > 0) setActive((activeIndex + 1) % optionCount);
|
|
342
|
+
break;
|
|
343
|
+
|
|
344
|
+
case 'ArrowUp':
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
if (!isOpen) { renderMatches(getValue()); return; }
|
|
347
|
+
if (optionCount > 0) setActive((activeIndex - 1 + optionCount) % optionCount);
|
|
348
|
+
break;
|
|
349
|
+
|
|
350
|
+
case 'Enter':
|
|
351
|
+
if (isOpen && activeIndex >= 0 && matches[activeIndex]) {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
selectValue(matches[activeIndex]);
|
|
354
|
+
}
|
|
355
|
+
break;
|
|
356
|
+
|
|
357
|
+
case 'Escape':
|
|
358
|
+
if (isOpen) {
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
closeListbox();
|
|
361
|
+
}
|
|
362
|
+
break;
|
|
363
|
+
|
|
364
|
+
case 'Tab':
|
|
365
|
+
closeListbox();
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Close when clicking outside the combobox/listbox.
|
|
371
|
+
document.addEventListener('click', (e) => {
|
|
372
|
+
if (!e.target.closest('.combobox') && !e.target.closest('.combobox-listbox')) {
|
|
373
|
+
closeListbox();
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
</script>
|
|
377
|
+
|
|
378
|
+
</body>
|
|
379
|
+
</html>
|
package/public/service-worker.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { Request, Response, URL, clients} = self;
|
|
2
2
|
// Little point in trying to automatically pull this through package.json, as we need a byte change in THIS file to trigger a new worker.
|
|
3
|
-
const serviceVersion = '4.5.
|
|
3
|
+
const serviceVersion = '4.5.9';
|
|
4
4
|
|
|
5
5
|
const cacheList = [ // The files we need.
|
|
6
6
|
"/",
|
|
@@ -72,13 +72,31 @@ md-outlined-text-field {
|
|
|
72
72
|
max-height: 80vh;
|
|
73
73
|
overflow: auto;
|
|
74
74
|
}
|
|
75
|
+
/* Safari. Example dimensions are iPhone 17 pro max simulator */
|
|
76
|
+
/* In landscape, regardless of compact/bottom/top setting: */
|
|
77
|
+
/* - There is a thick tabbed address bar above map. */
|
|
78
|
+
/* - Notch partially obscures logo in landscape-right, but that's ok */
|
|
79
|
+
/* - Height should be dvh (956 x 330), as lvh (440) extends below bottom of screen. */
|
|
80
|
+
/* In portrait, regardless of compact/bottom/top setting: */
|
|
81
|
+
/* - There is a white space at top and bottom that is a little larger than the stuff controls displayed in it. */
|
|
82
|
+
/* - If height of map, etc, is dvh (440 x 796), everything works, including keyboard appearance and dismissal. */
|
|
83
|
+
/* Position .watching-hashtags box bottom:18px to leave space for map attribution. */
|
|
84
|
+
/* - If height of map is lvh (440 x 836) and #subPopoverControls remains dvh (.watching-hashtags bottom:0), */
|
|
85
|
+
/* then the map has a nice-ish fade under the bottom controls, without losing so much to whitespace. */
|
|
86
|
+
/* BUT: */
|
|
87
|
+
/* - It still has a hard edge to start of white, that is now directly under controls, which is ugly. */
|
|
88
|
+
/* - The map attribution is under the controls, which isn't fair. */
|
|
89
|
+
/* - I don't know how to make the keyboard work without covering the input box. */
|
|
75
90
|
#map {
|
|
76
|
-
width:
|
|
77
|
-
height:
|
|
91
|
+
width: 100dvw;
|
|
92
|
+
height: 100dvh;
|
|
78
93
|
position: absolute;
|
|
79
94
|
top: 0;
|
|
80
95
|
left: 0;
|
|
81
96
|
}
|
|
97
|
+
#mapCapture {
|
|
98
|
+
height: 100dvh;
|
|
99
|
+
}
|
|
82
100
|
|
|
83
101
|
.info-banner {
|
|
84
102
|
position: absolute;
|
|
@@ -115,9 +133,6 @@ md-outlined-text-field {
|
|
|
115
133
|
transition: opacity 2s;
|
|
116
134
|
}
|
|
117
135
|
|
|
118
|
-
#mapCapture {
|
|
119
|
-
height: 100vh;
|
|
120
|
-
}
|
|
121
136
|
#mapCapture > img {
|
|
122
137
|
position: absolute;
|
|
123
138
|
opacity: 0;
|
|
@@ -155,6 +170,7 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
155
170
|
height: 100dvh;
|
|
156
171
|
position: fixed;
|
|
157
172
|
filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.5));
|
|
173
|
+
--mapAttributionHeight: 18px;
|
|
158
174
|
}
|
|
159
175
|
#subPopoverControls > * > * {
|
|
160
176
|
pointer-events: auto;
|
|
@@ -170,19 +186,64 @@ button, .leaflet-control-zoom > a, .leaflet-popup-close-button, md-outlined-icon
|
|
|
170
186
|
.watching-hashtags {
|
|
171
187
|
position: absolute;
|
|
172
188
|
width: calc(100% - 130px);
|
|
173
|
-
bottom: calc((sign(calc(100vh - 100dvh)) - 1) * -18px);
|
|
189
|
+
/*bottom: calc((sign(calc(100vh - 100dvh)) - 1) * -18px); *//* 18px for attribution in desktop, none if iphone dynamic viewsize. */
|
|
190
|
+
bottom: var(--mapAttributionHeight);
|
|
174
191
|
right: 5px;
|
|
175
192
|
flex-direction: row-reverse;
|
|
176
193
|
flex-wrap: wrap-reverse;
|
|
177
194
|
align-items: flex-end;
|
|
178
195
|
}
|
|
196
|
+
.combobox {
|
|
197
|
+
position: relative;
|
|
198
|
+
--textFieldHeight: 34px;
|
|
199
|
+
--minWidth: 125px;
|
|
200
|
+
}
|
|
201
|
+
.combobox-listbox {
|
|
202
|
+
padding: 5px;
|
|
203
|
+
position: absolute;
|
|
204
|
+
bottom: calc(var(--textFieldHeight) + 2px);
|
|
205
|
+
left: 0;
|
|
206
|
+
max-height: 50dvh; /* leaving room for portrait keyboard. landscape keyboard has no room at all */
|
|
207
|
+
width: fit-content;
|
|
208
|
+
min-width: var(--minWidth);
|
|
209
|
+
overflow-y: auto;
|
|
210
|
+
z-index: 9999;
|
|
211
|
+
background: rgba(255, 255, 255, 0.95);
|
|
212
|
+
border-top-left-radius: 12px;
|
|
213
|
+
border-top-right-radius: 12px;
|
|
214
|
+
}
|
|
215
|
+
.combobox-option {
|
|
216
|
+
font-size: 0.95rem;
|
|
217
|
+
color: var(--md-sys-color-primary);
|
|
218
|
+
cursor: pointer;
|
|
219
|
+
white-space: nowrap;
|
|
220
|
+
}
|
|
221
|
+
.combobox-option minidenticon-svg {
|
|
222
|
+
display: inline-block;
|
|
223
|
+
position: relative;
|
|
224
|
+
top: 5px;
|
|
225
|
+
height: 20px;
|
|
226
|
+
width: 20px;
|
|
227
|
+
}
|
|
228
|
+
.combobox-option mark {
|
|
229
|
+
background: none;
|
|
230
|
+
color: var(--md-sys-color-secondary);
|
|
231
|
+
font-weight: 900;
|
|
232
|
+
}
|
|
233
|
+
.combobox-empty {
|
|
234
|
+
font-style: italic;
|
|
235
|
+
}
|
|
236
|
+
.combobox-option:hover,
|
|
237
|
+
.combobox-option.active {
|
|
238
|
+
background: #B8C2D7;
|
|
239
|
+
}
|
|
179
240
|
.watching-hashtags md-filled-text-field.newtag {
|
|
180
241
|
--md-filled-field-top-space: 0px;
|
|
181
242
|
--md-filled-field-bottom-space: 0px;
|
|
182
243
|
--md-filled-text-field-leading-space: 8px;
|
|
183
244
|
--md-filled-text-field-trailing-space: 0px;
|
|
184
|
-
|
|
185
|
-
|
|
245
|
+
height: var(--textFieldHeight);
|
|
246
|
+
width: var(--minWidth);
|
|
186
247
|
}
|
|
187
248
|
.alert-marker {
|
|
188
249
|
text-shadow: 8px -3px 10px rgb(0 0 0 / 60%);
|
package/spec/conversationSpec.js
CHANGED
|
@@ -31,8 +31,8 @@ describe("Conversation", function () {
|
|
|
31
31
|
let keep = true;
|
|
32
32
|
let tag = "caching";
|
|
33
33
|
class CacheConversation extends Conversation {
|
|
34
|
-
update() { return keep &&
|
|
35
|
-
initialize() { return keep &&
|
|
34
|
+
update(properties) { return keep && super.update(properties); }
|
|
35
|
+
initialize(properties) { return keep && super.initialize(properties); }
|
|
36
36
|
}
|
|
37
37
|
it("ends with explicit removal.", async function () {
|
|
38
38
|
let initial = await Conversation.ensure({tag, agent, payload});
|