@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 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.8",
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.29.0",
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",
@@ -128,7 +128,7 @@
128
128
  <!-- <hr class="rule"> -->
129
129
 
130
130
  <h1>The Bigger Picture</h1>
131
- <p>The Civil Defense software was created by a group of experienced system developers, running 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>
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
 
@@ -113,7 +113,7 @@
113
113
  </ul>
114
114
 
115
115
  <h1>El Panorama General</h1>
116
- <p>El software de Defensa Civil fue creado por un grupo de desarrolladores de sistemas experimentados, que se ejecuta en el <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>
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
- "leaflet": "./leaflet/leaflet-src.esm.js",
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
- <div id="mapCapture">
49
- <div id="map"></div>
50
- <img src="images/civil-defense-240.png" alt="civildefense" class="about-image">
51
- </div>
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
- <button id="aboutButton" class="about-button">
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
- <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">
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
- <md-chip-set class="watching-hashtags"></md-chip-set>
72
- </div>
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
- <div id="info" class="info-banner loading"></div>
71
+ <md-chip-set class="watching-hashtags"></md-chip-set>
72
+ </div>
75
73
 
76
- <div id="qrContainer" class="hidden">
77
- <div class="display-content"></div>
78
- </div>
74
+ <div id="info" class="info-banner loading"></div>
79
75
 
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>
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
- <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>
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
- <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>
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
- <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>
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
- <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>
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
- <md-menu positioning="popover" id="popoverMenu"></md-menu>
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.trackPublicChanges(region);
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: this.subject, region, killTag, payload: null});
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
- return null;
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 && container.removeItem(tag)?.destroy();
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
- return items.splice(items.findIndex(reply => reply.tag === tag), 1)?.[0];
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(tag) + tag;
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
- tags.sort((a, b) => stripLeadingEmoji(a).localeCompare(stripLeadingEmoji(b)));
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
- `<md-filled-text-field class="newtag" placeholder="➕${Int`add topic`}"></md-filled-text-field>`);
143
- clickTip(this.chipset.firstChild, Int`Add a new topic for which the map should show any alerts.`, event => { // Focusing "add topic".
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
- showMessage(Int`Type a new topic name to see any alerts on the map with this topic.`, 'instructions');
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
- this.chipset.firstChild.onchange = event => { // Add the new hashtag.
149
- resetInactivityTimer();
150
- let tag = event.target.value.trim() // Get into standard form, but do not strip emoji or case into canonical yet.
151
- .replace(/^#/, '') // No leading hash
152
- .replace(/\s+/g, ' ') // Replace multiple spaces with a single space
153
- .normalize('NFD'); // Standardize different ways of making accents into decomposed form - but do not remove them.
154
- if (!tag) return;
155
- Alert.closePopup();
156
- tag = this.add(tag); // Might exist, in which case tag might now be extended.
157
- this.setPublish(tag);
158
- this.onchange({highlightPublish: true});
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 persisted = JSON.parse(localStorage.getItem('hashtags') ||
203
- `{"🍰 ${Int`cake`}": true, "🔥 ${Int`fire`}": true, "🌊 ${Int`flood`}": true, "🆘 ${Int`help`}": "pub", "🧊 ${Int`ice`}": true}`);
204
- //`{"🎙️ news":"pub", "🎸 classic rock":true, "🎷 jazz":true, "🎵 pop":true, "🎤 hiphop":true, "🎧 edm":true, "🤠 country":true, "🎼 classical":true}`);
205
- //`{"🍰 ${Int`cake`}": true, "🔥 ${Int`fire`}": true, "🌊 ${Int`flood`}": true, "🆘 ${Int`help`}": "pub", "🧊 ${Int`ice`}": true, "🎙️ news":true, "🎸 classic rock":true, "🎷 jazz":true, "🎵 pop":true, "🎤 hiphop":true, "🎧 edm":true, "🤠 country":true, "🎼 classical":true}`);
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
- tooltip('.leaflet-control-zoom-in', Int`Zoom in to show more detail in the map.`);
159
- tooltip('.leaflet-control-zoom-out', Int`Zoom out to show a larger area in the map.`);
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>
@@ -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.8';
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: 100vw;
77
- height: 100vh;
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); /* 18px for attribution in desktop, none if iphone dynamic viewsize. */
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
- width: 125px;
185
- height: 34px;
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%);
@@ -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 && this; }
35
- initialize() { return keep && this; }
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});